Merge pull request #258 from ChrispyBacon-dev/unstable
Some checks are pending
Docker Image Build and Push / build_self_hosted (push) Waiting to run
Docker Image Build and Push / build_github_hosted_fallback (push) Blocked by required conditions

Enhanced API Key Management
This commit is contained in:
Chris 2025-09-30 10:24:29 +02:00 committed by GitHub
commit 99bbbcfbb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 422 additions and 13 deletions

View file

@ -7,10 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [v3.0.2] - 2025-09-30
Of course, my apologies for the misunderstanding. Here is the changelog with a new, cleanly formatted entry for the hotfix on September 27th, while keeping the September 26th entry separate as you requested.
### Added
- **Enhanced API Key Management**
- **Revoked Key Visibility:** Revoked API keys are now displayed in a separate "Revoked Keys" section with full key visibility for verification and audit purposes.
- **Permanent Deletion:** Added "Delete Permanently" functionality for individual revoked keys and "Clear All" for bulk removal.
- **Auto-Cleanup System:** Implemented automatic cleanup of revoked keys after 30 days with manual trigger option.
- **Improved UX:** Revoked keys are visually distinguished (grayed out, full key shown) with countdown to auto-deletion.
- **Copy Functionality:** Users can copy full revoked API keys for record-keeping before permanent deletion.
### Fixed
- **API Key Revocation Display Bug:** Fixed issue where revoked API keys remained visible in the frontend as if they were active, even though backend authentication correctly rejected them.
---
## [v3.0.1] (Hotfixes) - 2025-09-27
### Added

View file

@ -20,7 +20,7 @@ import os
import logging
# --- DockFlare Version ---
APP_VERSION = "v3.0.1"
APP_VERSION = "v3.0.2"
# --- web: https://dockflare.app ---
# --- github: https://github.com/ChrispyBacon-dev/DockFlare ---

View file

@ -323,6 +323,110 @@ def list_agent_keys():
"""Return a shallow copy of the agent key metadata from the encrypted store."""
return agent_key_store.list_keys()
def cleanup_expired_revoked_keys(retention_days=30):
"""
Auto-cleanup revoked keys older than retention_days.
Returns dict with cleanup results.
"""
if retention_days <= 0:
return {"status": "skipped", "message": "Auto-cleanup disabled"}
all_keys = agent_key_store.list_keys()
now = datetime.utcnow().replace(tzinfo=timezone.utc)
expired_keys = []
cleaned_count = 0
for key_id, key_info in all_keys.items():
if key_info.get("status") != "revoked":
continue
revoked_at_str = key_info.get("revoked_at")
if not revoked_at_str:
continue
try:
# Parse revocation timestamp
if revoked_at_str.endswith('Z'):
revoked_at = datetime.fromisoformat(revoked_at_str.replace('Z', '+00:00'))
else:
revoked_at = datetime.fromisoformat(revoked_at_str)
revoked_at = revoked_at.replace(tzinfo=timezone.utc) if revoked_at.tzinfo is None else revoked_at.astimezone(timezone.utc)
# Check if key is expired
days_since_revoked = (now - revoked_at).days
if days_since_revoked >= retention_days:
owner = key_info.get("owner", "unknown")
expired_keys.append({
"key_id": key_id,
"owner": owner,
"revoked_at": revoked_at_str,
"days_old": days_since_revoked
})
# Remove the expired key
agent_key_store.remove_key(key_id)
cleaned_count += 1
logging.info(f"AUTO_CLEANUP: Removed expired revoked key {key_id[:8]}... (owner: {owner}, revoked {days_since_revoked} days ago)")
except Exception as e:
logging.warning(f"AUTO_CLEANUP: Failed to process revoked key {key_id[:8]}: {e}")
result = {
"status": "completed",
"cleaned_count": cleaned_count,
"retention_days": retention_days,
"expired_keys": expired_keys
}
if cleaned_count > 0:
logging.info(f"AUTO_CLEANUP: Removed {cleaned_count} expired revoked keys (retention: {retention_days} days)")
return result
def get_revoked_keys_summary():
"""
Get summary information about revoked keys for display.
Returns dict with revoked key counts and aging info.
"""
all_keys = agent_key_store.list_keys()
now = datetime.utcnow().replace(tzinfo=timezone.utc)
revoked_keys = []
for key_id, key_info in all_keys.items():
if key_info.get("status") != "revoked":
continue
revoked_at_str = key_info.get("revoked_at")
days_until_cleanup = None
if revoked_at_str:
try:
if revoked_at_str.endswith('Z'):
revoked_at = datetime.fromisoformat(revoked_at_str.replace('Z', '+00:00'))
else:
revoked_at = datetime.fromisoformat(revoked_at_str)
revoked_at = revoked_at.replace(tzinfo=timezone.utc) if revoked_at.tzinfo is None else revoked_at.astimezone(timezone.utc)
# Calculate days until auto-cleanup (assuming 30 day retention)
days_since_revoked = (now - revoked_at).days
days_until_cleanup = max(0, 30 - days_since_revoked)
except Exception:
pass
revoked_keys.append({
"key_id": key_id,
"owner": key_info.get("owner", "unknown"),
"revoked_at": revoked_at_str,
"days_until_cleanup": days_until_cleanup
})
return {
"revoked_count": len(revoked_keys),
"revoked_keys": revoked_keys
}
def get_agent_rules(agent_id):
"""Return all active rules for a specific agent."""
with state_lock:

View file

@ -44,9 +44,10 @@
</section>
<!-- Active API Keys Section -->
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<h2 class="card-title text-xl border-b border-base-300 pb-3 mb-6">API Keys</h2>
<h2 class="card-title text-xl border-b border-base-300 pb-3 mb-6">Active API Keys</h2>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm w-full">
<thead>
@ -54,16 +55,63 @@
<th class="p-3">Key (Partial)</th>
<th class="p-3">Owner</th>
<th class="p-3">Created At</th>
<th class="p-3">Status</th>
<th class="p-3">Actions</th>
</tr>
</thead>
<tbody id="api-keys-table-body">
<tbody id="active-keys-table-body">
</tbody>
</table>
</div>
</div>
</section>
<!-- Revoked API Keys Section -->
<section class="card bg-base-100 shadow-xl mb-8 sm:mb-12 transition-all duration-300 hover:shadow-2xl">
<div class="card-body">
<div class="flex flex-col sm:flex-row items-center justify-between border-b border-base-300 pb-3 mb-6">
<h2 class="card-title text-xl">Revoked Keys <span id="revoked-count-badge" class="badge badge-ghost badge-sm ml-2">0</span></h2>
<div class="flex items-center gap-2 mt-4 sm:mt-0">
<button id="btn-cleanup-expired" class="btn btn-ghost btn-sm" title="Remove keys revoked >30 days ago">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Auto Cleanup
</button>
<button id="btn-clear-all-revoked" class="btn btn-warning btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
Clear All
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm w-full">
<thead>
<tr>
<th class="p-3">Full API Key</th>
<th class="p-3">Owner</th>
<th class="p-3">Revoked At</th>
<th class="p-3">Auto-Delete</th>
<th class="p-3">Actions</th>
</tr>
</thead>
<tbody id="revoked-keys-table-body">
</tbody>
</table>
<div id="no-revoked-keys" class="text-center p-8 text-base-content/60 hidden">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 mx-auto mb-4 opacity-30">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-lg font-medium">No revoked keys</p>
<p class="text-sm">Revoked API keys will appear here for cleanup</p>
</div>
</div>
</div>
</section>
{% endblock %}
{% block modals %}
@ -421,9 +469,12 @@
});
};
const agentsTableBody = document.getElementById('agents-table-body');
const apiKeysTableBody = document.getElementById('api-keys-table-body');
const activeKeysTableBody = document.getElementById('active-keys-table-body');
const revokedKeysTableBody = document.getElementById('revoked-keys-table-body');
const revokedCountBadge = document.getElementById('revoked-count-badge');
const noRevokedKeysMessage = document.getElementById('no-revoked-keys');
const renderAgentsTable = (agents) => {
@ -545,24 +596,118 @@
const renderApiKeysTable = (apiKeys) => {
apiKeysTableBody.innerHTML = '';
if (Object.keys(apiKeys).length === 0) {
apiKeysTableBody.innerHTML = '<tr><td colspan="4" class="text-center p-4">No API keys have been generated.</td></tr>';
// Separate active and revoked keys
const activeKeys = {};
const revokedKeys = {};
for (const [keyId, meta] of Object.entries(apiKeys)) {
if (meta.status === 'revoked') {
revokedKeys[keyId] = meta;
} else {
activeKeys[keyId] = meta;
}
}
// Render active keys
renderActiveKeysTable(activeKeys);
// Render revoked keys
renderRevokedKeysTable(revokedKeys);
// Update revoked count badge
const revokedCount = Object.keys(revokedKeys).length;
revokedCountBadge.textContent = revokedCount;
revokedCountBadge.className = revokedCount > 0 ? 'badge badge-warning badge-sm ml-2' : 'badge badge-ghost badge-sm ml-2';
};
const renderActiveKeysTable = (activeKeys) => {
activeKeysTableBody.innerHTML = '';
if (Object.keys(activeKeys).length === 0) {
activeKeysTableBody.innerHTML = '<tr><td colspan="5" class="text-center p-4">No active API keys.</td></tr>';
return;
}
for (const [keyId, meta] of Object.entries(apiKeys)) {
for (const [keyId, meta] of Object.entries(activeKeys)) {
const owner = meta.owner || '<i>N/A</i>';
const createdAt = meta.created_at ? new Date(meta.created_at).toLocaleString() : '<i>N/A</i>';
const lastUsed = meta.last_used_at ? new Date(meta.last_used_at).toLocaleString() : 'Never used';
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="p-3 whitespace-nowrap font-mono break-all">${keyId.substring(0, 8)}...</td>
<td class="p-3 whitespace-nowrap">${owner}</td>
<td class="p-3 whitespace-nowrap">${createdAt}</td>
<td class="p-3 whitespace-nowrap">
<span class="badge badge-success badge-sm">Active</span>
<br><small class="text-xs opacity-60">${lastUsed}</small>
</td>
<td class="p-3 whitespace-nowrap">
<button class="btn btn-xs btn-error btn-revoke-key" data-key-id="${keyId}">Revoke</button>
</td>
`;
apiKeysTableBody.appendChild(tr);
activeKeysTableBody.appendChild(tr);
}
};
const renderRevokedKeysTable = (revokedKeys) => {
revokedKeysTableBody.innerHTML = '';
const revokedCount = Object.keys(revokedKeys).length;
if (revokedCount === 0) {
noRevokedKeysMessage.classList.remove('hidden');
return;
} else {
noRevokedKeysMessage.classList.add('hidden');
}
for (const [keyId, meta] of Object.entries(revokedKeys)) {
const owner = meta.owner || '<i>N/A</i>';
const revokedAt = meta.revoked_at ? new Date(meta.revoked_at).toLocaleString() : '<i>N/A</i>';
// Calculate days until auto-cleanup (assuming 30 days retention)
let autoDeleteInfo = 'Unknown';
if (meta.revoked_at) {
try {
const revokedDate = new Date(meta.revoked_at);
const now = new Date();
const daysSinceRevoked = Math.floor((now - revokedDate) / (1000 * 60 * 60 * 24));
const daysUntilCleanup = Math.max(0, 30 - daysSinceRevoked);
if (daysUntilCleanup === 0) {
autoDeleteInfo = '<span class="text-warning">Ready for cleanup</span>';
} else {
autoDeleteInfo = `${daysUntilCleanup} days`;
}
} catch (e) {
autoDeleteInfo = 'Error';
}
}
const tr = document.createElement('tr');
tr.className = 'opacity-60 italic';
tr.innerHTML = `
<td class="p-3 font-mono break-all text-sm">
<div class="bg-base-200 p-2 rounded border-2 border-dashed">
<span class="text-xs opacity-75">Full Key:</span><br>
<code class="text-xs">${keyId}</code>
<button class="btn btn-xs btn-ghost ml-2 btn-copy-revoked-key" data-key="${keyId}">Copy</button>
</div>
</td>
<td class="p-3 whitespace-nowrap line-through">${owner}</td>
<td class="p-3 whitespace-nowrap">
<span class="badge badge-error badge-sm">Revoked</span>
<br><small class="text-xs">${revokedAt}</small>
</td>
<td class="p-3 whitespace-nowrap">
${autoDeleteInfo}
</td>
<td class="p-3 whitespace-nowrap">
<button class="btn btn-xs btn-error btn-delete-key-permanently" data-key-id="${keyId}">
Delete Permanently
</button>
</td>
`;
revokedKeysTableBody.appendChild(tr);
}
};
@ -786,7 +931,8 @@
}
});
apiKeysTableBody.addEventListener('click', async (e) => {
// Event listeners for active keys table
activeKeysTableBody.addEventListener('click', async (e) => {
if (e.target.classList.contains('btn-revoke-key')) {
const keyId = e.target.getAttribute('data-key-id');
if (confirm(`Are you sure you want to revoke API key "${keyId.substring(0, 8)}..."? This action is irreversible.`)) {
@ -799,6 +945,67 @@
}
});
// Event listeners for revoked keys table
revokedKeysTableBody.addEventListener('click', async (e) => {
if (e.target.classList.contains('btn-delete-key-permanently')) {
const keyId = e.target.getAttribute('data-key-id');
if (confirm(`Are you sure you want to permanently delete this revoked API key? This cannot be undone.\n\nKey: ${keyId.substring(0, 8)}...`)) {
try {
const success = await apiCall(`/api/v2/agents/keys/${keyId}`, 'DELETE');
if (success) {
alert('API key permanently deleted.');
fetchData();
}
} catch (error) {
alert('Failed to delete API key. Please try again.');
}
}
} else if (e.target.classList.contains('btn-copy-revoked-key')) {
const keyText = e.target.getAttribute('data-key');
copyToClipboard(keyText, e.target);
}
});
// Clear all revoked keys button
document.getElementById('btn-clear-all-revoked').addEventListener('click', async () => {
const revokedCount = document.querySelectorAll('#revoked-keys-table-body tr').length;
if (revokedCount === 0) {
alert('No revoked keys to clear.');
return;
}
if (confirm(`Are you sure you want to permanently delete all ${revokedCount} revoked API keys? This cannot be undone.`)) {
try {
const result = await apiCall('/api/v2/agents/keys/revoked', 'DELETE');
if (result) {
alert(`Successfully deleted ${result.deleted_count} revoked keys.`);
fetchData();
}
} catch (error) {
alert('Failed to clear revoked keys. Please try again.');
}
}
});
// Auto cleanup button
document.getElementById('btn-cleanup-expired').addEventListener('click', async () => {
if (confirm('This will permanently delete revoked API keys older than 30 days. Continue?')) {
try {
const result = await apiCall('/api/v2/agents/keys/cleanup', 'POST', { retention_days: 30 });
if (result) {
if (result.cleaned_count > 0) {
alert(`Auto-cleanup completed: ${result.cleaned_count} expired keys removed.`);
fetchData();
} else {
alert('No expired keys found to clean up.');
}
}
} catch (error) {
alert('Auto-cleanup failed. Please try again.');
}
}
});
document.getElementById('form-rename-agent').addEventListener('submit', async (e) => {
e.preventDefault();
const agentId = document.getElementById('rename-agent-id').value;

View file

@ -29,8 +29,9 @@ from app import config, docker_client, tunnel_state, cloudflared_agent_state, pu
from app.core.state_manager import (
managed_rules, state_lock, save_state,
add_agent, get_agent, update_agent, list_agents, remove_agent, add_agent_key, revoke_agent_key, find_agent_id_by_key, list_agent_keys, get_agent_key_info,
get_services_snapshot
get_services_snapshot, cleanup_expired_revoked_keys, get_revoked_keys_summary
)
from app.core import agent_key_store
from app.core.tunnel_manager import (
start_cloudflared_container,
stop_cloudflared_container,
@ -978,6 +979,92 @@ def agents_revoke_key():
else:
return jsonify({"status": "error", "message": "Key not found."}), 404
@api_v2_bp.route('/agents/keys/<key_id>', methods=['DELETE'])
def delete_agent_key_permanently(key_id):
"""
Admin endpoint to permanently delete a revoked agent API key.
Only revoked keys can be permanently deleted.
"""
if not key_id:
return jsonify({"status": "error", "message": "Missing key ID"}), 400
# Get key info to validate it exists and is revoked
key_info = get_agent_key_info(key_id)
if not key_info:
return jsonify({"status": "error", "message": "Key not found"}), 404
# Security: Only allow deletion of revoked keys
if key_info.get("status") != "revoked":
return jsonify({"status": "error", "message": "Can only permanently delete revoked keys"}), 400
# Audit logging before deletion
owner = key_info.get("owner", "unknown")
revoked_at = key_info.get("revoked_at", "unknown")
logging.info(f"ADMIN: Permanently deleting revoked key {key_id[:8]}... (owner: {owner}, revoked: {revoked_at})")
# Perform the permanent deletion
agent_key_store.remove_key(key_id)
return jsonify({
"status": "success",
"message": "Key permanently deleted",
"deleted_key": key_id[:8] + "...",
"owner": owner
}), 200
@api_v2_bp.route('/agents/keys/revoked', methods=['DELETE'])
def delete_all_revoked_keys():
"""
Admin endpoint to permanently delete all revoked agent API keys.
"""
all_keys = list_agent_keys()
revoked_keys = {k: v for k, v in all_keys.items() if v.get("status") == "revoked"}
if not revoked_keys:
return jsonify({"status": "success", "message": "No revoked keys to delete"}), 200
deleted_count = 0
deleted_keys = []
for key_id, key_info in revoked_keys.items():
try:
owner = key_info.get("owner", "unknown")
revoked_at = key_info.get("revoked_at", "unknown")
logging.info(f"ADMIN: Bulk deleting revoked key {key_id[:8]}... (owner: {owner}, revoked: {revoked_at})")
agent_key_store.remove_key(key_id)
deleted_keys.append({"key": key_id[:8] + "...", "owner": owner})
deleted_count += 1
except Exception as e:
logging.error(f"Failed to delete revoked key {key_id[:8]}: {e}")
logging.info(f"ADMIN: Bulk deleted {deleted_count} revoked keys")
return jsonify({
"status": "success",
"message": f"Permanently deleted {deleted_count} revoked keys",
"deleted_count": deleted_count,
"deleted_keys": deleted_keys
}), 200
@api_v2_bp.route('/agents/keys/cleanup', methods=['POST'])
def trigger_key_cleanup():
"""
Admin endpoint to manually trigger cleanup of expired revoked keys.
"""
data = request.get_json() or {}
retention_days = data.get('retention_days', 30)
if not isinstance(retention_days, int) or retention_days < 1:
return jsonify({"status": "error", "message": "retention_days must be a positive integer"}), 400
try:
result = cleanup_expired_revoked_keys(retention_days)
return jsonify(result), 200
except Exception as e:
logging.error(f"Manual cleanup failed: {e}", exc_info=True)
return jsonify({"status": "error", "message": f"Cleanup failed: {str(e)}"}), 500
@api_v2_bp.route('/agents', methods=['GET'])
def agents_list_api():
"""