Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [v3.0.2] - 2025-09-30

### 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
- **Enhanced Country Selection UX**
- **Bulk Selection Controls:** Added "Select All," "Select None," and "Invert Selection" buttons for more efficient country management.
- **Quick Templates:** Implemented one-click presets such as "Block All Except US," "Block All Except EU," and "Block High Risk Countries."
- **Regional Selection:** Users can now select entire continents (e.g., Africa, Asia, Europe) with a single click.
- **Visual Feedback:** A dynamic counter now shows "X of 245 countries selected" to provide immediate feedback.

### Fixed
- **IP Whitelist Access Policies:** Corrected an issue where IP-based access policies were not working as expected. DockFlare now correctly creates a `bypass` rule for IP whitelists and a separate `allow` rule for email-based authentication, ensuring whitelisted IPs can access services without an additional authentication step.
- **Access Policy Updates:** Fixed a bug where updating an existing ingress rule's Access Policy would fail with an "application already exists" error. DockFlare now correctly updates the existing Cloudflare Access application instead of trying to create a new one.
- **API Error Logging:** Reduced the severity of the log message for a `403 Forbidden` error when fetching a user's email. This is an expected and non-critical error when using a scoped API token.
(raised by @durzo issue tracker #216 #217)
- **Tedious Manual Selection:** Resolved an issue where "Allow US Only" required manually selecting over 194 countries; it now requires only one click (resolves #240).
- **IP Whitelist Access Policies:** Corrected a bug where IP-based access policies were not functioning as intended. DockFlare now properly creates a `bypass` rule for whitelisted IPs.
- **Access Policy Updates:** Addressed a failure where updating an Access Policy on an existing ingress rule would result in an "application already exists" error.
- **API Error Logging:** The severity of the log message for a `403 Forbidden` error during user email fetches has been reduced, as this is expected behavior with a scoped API token (related to issues #216, #217 raised by @durzo).
- **OAuth Provider Visibility:** Fixed the login screen to respect disabled providers immediately after changes through the API or UI, keeping password-disable overrides intact.

---
## [v3.0.1] - 2025-09-26
Expand Down
6 changes: 3 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ services:
restart: "no"

dockflare:
build: ./dockflare
#image: alplat/dockflare:stable
#build: ./dockflare
image: alplat/dockflare:stable
container_name: dockflare
restart: unless-stopped
ports:
- "5001:5000"
- "5000:5000"
#labels:
# -- Cloudflare Tunnel Configuration (via DockFlare) OPTIONAL --
# Main DockFlare interface with access policy
Expand Down
8 changes: 8 additions & 0 deletions dockflare/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,14 @@
@login_manager.unauthorized_handler
def unauthorized():
from flask import request, jsonify, redirect, url_for

if app_instance.config.get('DISABLE_PASSWORD_LOGIN', False):
from flask_login import login_user
from app.core.user import User
user = User('anonymous', auth_method='disabled')
login_user(user)
return redirect(request.url)
Comment thread Dismissed

if request.path.startswith('/api/'):
return jsonify({"status": "error", "message": "authentication_required"}), 401

Expand Down
2 changes: 1 addition & 1 deletion dockflare/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
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 ---

Expand Down
5 changes: 1 addition & 4 deletions dockflare/app/core/access_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,7 @@ def get_cloudflare_account_email():
logging.warning(f"Failed to fetch Cloudflare account email, API call unsuccessful. Response: {response_data}")
return None
except requests.exceptions.RequestException as e:
if hasattr(e, 'response') and e.response is not None and e.response.status_code == 403:
logging.info("Could not fetch Cloudflare account email due to permissions. This is expected if using a scoped API token without User.Read permissions. The TLD policy feature will be disabled.")
else:
logging.error(f"API error fetching Cloudflare account email: {e}")
logging.error(f"API error fetching Cloudflare account email: {e}")
return None
except Exception as e:
logging.error(f"Unexpected error fetching Cloudflare account email: {e}", exc_info=True)
Expand Down
104 changes: 104 additions & 0 deletions dockflare/app/core/state_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion dockflare/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ def main_application_entrypoint():
def register_oauth_providers(flask_app, oauth_instance):
providers = flask_app.config.get('OAUTH_PROVIDERS', [])
for provider in providers:
if not provider.get('enabled'):
if not provider.get('enabled', True):
continue

try:
Expand Down
2 changes: 1 addition & 1 deletion dockflare/app/static/css/output.css

Large diffs are not rendered by default.

33 changes: 26 additions & 7 deletions dockflare/app/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,7 @@ function initializeAllTomSelects() {
manualTunnelTomSelect = new TomSelect(manualTunnelSelect, singleSelectOptions);
}

// Initialize select on the Access Policies page (Access Group modal)
const countrySelect = document.getElementById('group_countries');
if (countrySelect) {
new TomSelect(countrySelect, countrySelectOptions);
}
// Note: country selector is now initialized in access_policies.html with enhanced features
}

const themeManager = (function() {
Expand Down Expand Up @@ -1061,6 +1057,9 @@ function openCreateAccessGroupModal() {
if (countrySelect && countrySelect.tomselect) {
countrySelect.tomselect.clear();
countrySelect.tomselect.sync();
if (window.enhancedCountrySelector && window.enhancedCountrySelector.updateSelectionCounter) {
window.enhancedCountrySelector.updateSelectionCounter();
}
}

modal.showModal();
Expand Down Expand Up @@ -1130,7 +1129,10 @@ function openEditAccessGroupModal(groupId, details) {
const countrySelect = document.getElementById('group_countries');
if (countrySelect && countrySelect.tomselect) {
countrySelect.tomselect.setValue(selectedCountries);
} else if (countrySelect) { // Fallback for when TomSelect isn't initialized
if (window.enhancedCountrySelector && window.enhancedCountrySelector.updateSelectionCounter) {
window.enhancedCountrySelector.updateSelectionCounter();
}
} else if (countrySelect) {
Array.from(countrySelect.options).forEach(option => {
option.selected = selectedCountries.includes(option.value);
});
Expand Down Expand Up @@ -1307,8 +1309,25 @@ document.addEventListener('DOMContentLoaded', function() {
}
};

const clearAccessGroupOnPolicyChange = () => {
if (select.value && select.value !== 'none') {
const accessGroupSelect = container.querySelector('#manual_access_group, #edit_manual_access_group');
if (accessGroupSelect) {
if (accessGroupSelect.tomselect) {
accessGroupSelect.tomselect.clear();
} else {
accessGroupSelect.value = '';
}
accessGroupSelect.dispatchEvent(new Event('change'));
}
}
};

// Add the event listener for user interactions
select.addEventListener('change', toggleEmailField);
select.addEventListener('change', () => {
toggleEmailField();
clearAccessGroupOnPolicyChange();
});

});

Expand Down
Loading
Loading