mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-10-19 07:33:16 +00:00
Zero Trust Access Policy Management via Docker Labels
Some checks are pending
Docker Image Build and Push (Conditional Arch) / build (push) Waiting to run
Some checks are pending
Docker Image Build and Push (Conditional Arch) / build (push) Waiting to run
This commit is contained in:
parent
419122b3f4
commit
d7b8f33dfc
3 changed files with 36 additions and 591 deletions
51
README.MD
51
README.MD
|
|
@ -2,14 +2,14 @@
|
|||
[](https://github.com/ChrispyBacon-dev/baconflip)
|
||||
[]()
|
||||
[](https://en.wikipedia.org/wiki/Central_processing_unit)
|
||||
[](https://shields.io/)
|
||||
[](https://shields.io/)
|
||||
[](https://hub.docker.com/r/alplat/dockflare)
|
||||
[](https://www.python.org/)
|
||||
[](https://github.com/ChrispyBacon-dev/dockflare/issues)
|
||||
[](https://github.com/ChrispyBacon-dev/dockflare/commits/stable)
|
||||
[](https://github.com/ChrispyBacon-dev/dockflare/commits/stable)
|
||||
|
||||
DockFlare automates Cloudflare Tunnel ingress rule management based on Docker container labels, simplifying public exposure of your Dockerized applications. It eliminates manual Cloudflare configuration, acting as a self-hosted ingress controller.
|
||||
DockFlare automates Cloudflare Tunnel ingress rule management and Zero Trust Access policies based on Docker container labels, simplifying the secure public exposure of your Dockerized applications. It minimizes manual Cloudflare configuration, acting as a dynamic, self-hosted ingress controller and basic access manager.
|
||||
|
||||
## Key Features
|
||||
|
||||
|
|
@ -18,37 +18,54 @@ DockFlare automates Cloudflare Tunnel ingress rule management based on Docker co
|
|||
- **Dynamic Ingress via Docker Labels**:
|
||||
- Monitors Docker events for containers with labels (prefix: `cloudflare.tunnel.`): `enable="true"`, `hostname="subdomain.example.com"`, `service="http://target:port"`.
|
||||
- Automatically updates Cloudflare Tunnel configuration to match running, labeled containers.
|
||||
- **Multi-Domain Support**: Configure multiple domains for a single container using indexed labels, each with its own service target and zone configuration.
|
||||
- **Graceful Deletion**: Configurable grace period before removing ingress rules when a container stops.
|
||||
- **State Persistence**: Saves `managed_rules` to `state.json` for restarts.
|
||||
- **Zero Trust Access Policy Management via Docker Labels (NEW)**:
|
||||
- Define Access Policies (e.g., `bypass`, `authenticate`, `default_tld`, or fully custom JSON rules) for each hostname directly through Docker labels.
|
||||
- DockFlare automatically creates, updates, or deletes the corresponding Cloudflare Access Applications.
|
||||
- Supports configuring application name, session duration, IdP restrictions, and other Access Application settings via labels.
|
||||
- **Multi-Domain Support**: Configure multiple hostnames for a single container using indexed labels, each with its own service target, zone configuration, and Access Policy.
|
||||
- **Graceful Deletion**: Configurable grace period before removing ingress rules and associated Access Applications when a container stops.
|
||||
- **State Persistence**: Saves `managed_rules` (including Access Application IDs and configurations) to `state.json` for restarts.
|
||||
- **Optimized Reconciliation**:
|
||||
- On startup, ensures consistency between Docker containers, saved state, and Cloudflare configuration
|
||||
- Processes DNS operations in small batches to prevent API rate limiting
|
||||
- Shows real-time reconciliation progress in the UI
|
||||
- On startup and periodically, ensures consistency between Docker containers (labels), saved state, Cloudflare Tunnel configuration, and Cloudflare Access Applications.
|
||||
- Processes DNS operations in small batches.
|
||||
- Shows real-time reconciliation progress in the UI.
|
||||
- **Web UI**: Status dashboard with:
|
||||
- Tunnel & agent status.
|
||||
- Start/Stop agent controls.
|
||||
- Managed ingress rule list with status, container ID, deletion time, and "Force Delete" option.
|
||||
- **List of all Cloudflare Tunnels on Account (NEW)**: Shows all tunnels on the configured Cloudflare account (not just the one managed by this DockFlare instance), including their name, ID, status, creation date, and active connections. Users can click a '+' icon next to each tunnel to dynamically load and view its associated CNAME DNS records found within the configured zones.
|
||||
- Managed ingress rule list displaying:
|
||||
- Hostname, Service Target, Status, Container ID, Deletion Schedule.
|
||||
- **Current Access Policy**: Shows the type of Cloudflare Access Policy applied (e.g., Bypass, Authenticate, None (Public), Default TLD) with a direct link to the Access Application in Cloudflare.
|
||||
- **UI-Driven Access Policy Editing (NEW)**: Allows users to change the Access Policy for a managed rule directly from the web UI (e.g., switch from Bypass to Authenticate by Email, set to public, or revert to TLD default). *Note: If `cloudflare.tunnel.access.*` labels are present on the container, they will take precedence on container restart/reconciliation.*
|
||||
- "Force Delete" option for rules (also removes associated Access App).
|
||||
- **`*.tld` Policy Assistant (NEW)**:
|
||||
- Detects if a recommended wildcard (`*.yourdomain.com`) Access Policy is missing for your primary zone.
|
||||
- If missing, displays an alert in the UI explaining the benefits.
|
||||
- Provides a button to create a default secure `*.tld` policy (e.g., allow primary account email, deny others).
|
||||
- **List of all Cloudflare Tunnels on Account**: Shows all tunnels on the configured Cloudflare account, with an option to view their DNS records.
|
||||
- Real-time activity logs streamed directly to the browser.
|
||||
- Dark/Light mode toggle.
|
||||
- Basic reconciliation status display.
|
||||
- **Real-time Log Streaming**: View logs in real-time using Server-Sent Events (SSE).
|
||||
- **Content Security Policy (CSP)**: Ensures secure loading of resources and compatibility with reverse proxies.
|
||||
|
||||
[Learn more on the GitHub Wiki](https://github.com/ChrispyBacon-dev/DockFlare/wiki)
|
||||
[Learn more on the GitHub Wiki](https://github.com/ChrispyBacon-dev/DockFlare/wiki) *(You'll want to update the Wiki with details about the new `access.*` labels and the UI features!)*
|
||||
|
||||
 *(Consider updating this screenshot to show the new "Access Policy" and "Policy Actions" columns if possible)*
|
||||
|
||||

|
||||
## How It Works
|
||||
|
||||
DockFlare listens for Docker container events and manages Cloudflare Tunnel ingress rules dynamically. By labeling your containers, DockFlare automatically configures the tunnel and DNS records, ensuring your services are publicly accessible.
|
||||
DockFlare listens for Docker container events. By labeling your containers, DockFlare automatically configures Cloudflare Tunnel ingress rules, DNS records, and associated Cloudflare Zero Trust Access Applications, ensuring your services are publicly accessible according to your defined policies.
|
||||
|
||||
### Example Workflow
|
||||
|
||||
1. **Start DockFlare**: Run the DockFlare container with the required environment variables.
|
||||
2. **Label Your Containers**: Add labels to your Docker containers to define their public hostname and service target.
|
||||
3. **Automatic Configuration**: DockFlare detects the labeled containers and updates the Cloudflare Tunnel configuration and DNS records.
|
||||
4. **Graceful Deletion**: When a container stops, DockFlare schedules its ingress rule for deletion after a configurable grace period.
|
||||
1. **Start DockFlare**: Run the DockFlare container with the required environment variables (including Cloudflare API token with necessary permissions for Tunnels, DNS, and Access Apps).
|
||||
2. **Label Your Containers**: Add labels to your Docker containers to define their public hostname, service target, and optionally, their desired Access Policy.
|
||||
3. **Automatic Configuration**: DockFlare detects labeled containers and:
|
||||
- Updates the Cloudflare Tunnel configuration.
|
||||
- Creates/updates DNS CNAME records.
|
||||
- Creates/updates Cloudflare Access Applications based on `access.*` labels.
|
||||
4. **Manage Access Policies via UI (Optional)**: For quick adjustments, modify Access Policies for existing rules directly through the DockFlare web interface.
|
||||
5. **Graceful Deletion**: When a container stops, DockFlare schedules its ingress rule and any DockFlare-managed Access Application for deletion after a configurable grace period.
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
|
|
|||
|
|
@ -1,572 +0,0 @@
|
|||
<!--
|
||||
DockFlare: Automates Cloudflare Tunnel ingress from Docker labels.
|
||||
Copyright (C) 2025 ChrispyBacon-Dev <https://github.com/ChrispyBacon-dev/DockFlare>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>DockFlare - Cloudflare Tunnel ingress Manager</title>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const currentProtocol = window.location.protocol;
|
||||
const currentHost = window.location.host;
|
||||
function fixResources() {
|
||||
document.querySelectorAll('link[rel="stylesheet"]').forEach(function(link) {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && href.startsWith('http:') && window.location.protocol === 'https:') {
|
||||
link.setAttribute('href', href.replace('http:', 'https:'));
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('script[src]').forEach(function(script) {
|
||||
const src = script.getAttribute('src');
|
||||
if (src && src.startsWith('http:') && window.location.protocol === 'https:') {
|
||||
script.setAttribute('src', src.replace('http:', 'https:'));
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('link[rel="preconnect"]').forEach(function(link) {
|
||||
const href = link.getAttribute('href');
|
||||
if (href && href.startsWith('http:') && window.location.protocol === 'https:') {
|
||||
link.setAttribute('href', href.replace('http:', 'https:'));
|
||||
}
|
||||
});
|
||||
}
|
||||
fixResources();
|
||||
document.addEventListener('DOMContentLoaded', fixResources);
|
||||
const origFetch = window.fetch;
|
||||
window.fetch = function(url, options) {
|
||||
if (url && typeof url === 'string' && url.startsWith('http')) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.host === currentHost) {
|
||||
urlObj.protocol = currentProtocol;
|
||||
return origFetch.call(this, urlObj.toString(), options);
|
||||
}
|
||||
} catch (e) { console.error('Error processing URL:', e); }
|
||||
}
|
||||
return origFetch.call(this, url, options);
|
||||
};
|
||||
const baseTag = document.createElement('base');
|
||||
baseTag.href = currentProtocol + '//' + currentHost + '/';
|
||||
document.head.appendChild(baseTag);
|
||||
const preconnectDomains = ['cdn.tailwindcss.com', 'rsms.me'];
|
||||
preconnectDomains.forEach(domain => {
|
||||
const existingPreconnect = Array.from(document.querySelectorAll('link[rel="preconnect"]'))
|
||||
.find(link => link.href.includes(domain));
|
||||
if (existingPreconnect) {
|
||||
const href = existingPreconnect.getAttribute('href');
|
||||
if (href && href.startsWith('http') && new URL(href).protocol !== currentProtocol) {
|
||||
existingPreconnect.setAttribute('href', currentProtocol + '//' + new URL(href).host);
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link rel="preconnect" href="https://cdn.tailwindcss.com" crossorigin>
|
||||
<link rel="preconnect" href="https://rsms.me" crossorigin>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = { darkMode: 'class', theme: { extend: {} } }
|
||||
</script>
|
||||
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" crossorigin="anonymous">
|
||||
<style type="text/tailwindcss">
|
||||
#log-output .log-status { @apply italic text-gray-400 dark:text-gray-500; }
|
||||
#log-output .log-status.error { @apply not-italic text-red-500 dark:text-red-400 font-semibold; }
|
||||
#log-output .log-status.connected { @apply not-italic text-green-500 dark:text-green-400; }
|
||||
#log-output::-webkit-scrollbar { width: 8px; }
|
||||
#log-output::-webkit-scrollbar-track { background: transparent; }
|
||||
#log-output::-webkit-scrollbar-thumb { background-color: rgba(156, 163, 175, 0.5); border-radius: 4px; border: 2px solid transparent; background-clip: content-box; }
|
||||
#log-output::-webkit-scrollbar-thumb:hover { background-color: rgba(156, 163, 175, 0.7); }
|
||||
html.dark #log-output::-webkit-scrollbar-thumb { background-color: rgba(107, 114, 128, 0.5); }
|
||||
html.dark #log-output::-webkit-scrollbar-thumb:hover { background-color: rgba(107, 114, 128, 0.7); }
|
||||
</style>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; }
|
||||
.dark { color-scheme: dark; }
|
||||
.container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 0 1rem; }
|
||||
pre { font-family: monospace; }
|
||||
.connection-badge { display: inline-block; padding: 0.2em 0.4em; font-size: 85%; font-weight: 600; line-height: 1; text-align: center; white-space: nowrap; vertical-align: baseline; border-radius: 0.25rem; background-color: #6366f1; color: white; margin-right: 4px; margin-bottom: 4px; }
|
||||
html.dark .connection-badge { background-color: #818cf8; }
|
||||
.warning-text { color: #ef4444; font-weight: bold; }
|
||||
html.dark .warning-text { color: #f87171; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen flex flex-col font-sans transition-colors duration-300">
|
||||
|
||||
<div style="position: fixed; top: 0; left: 0; z-index: 50; pointer-events: none;">
|
||||
<div class="fixed top-6 left-[-38px] sm:top-7 sm:left-[-35px] z-50 transform -rotate-45 bg-indigo-600 dark:bg-indigo-500 text-white dark:text-white text-xs font-semibold uppercase tracking-wider text-center py-1 w-36 shadow-md">
|
||||
stable 1.4.0
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="sticky top-0 z-40 w-full bg-gray-100/90 dark:bg-gray-900/90 backdrop-blur-sm shadow-sm">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<img src="{{ url_for('static', filename='images/bannertr.png') }}" alt="Dockflare Logo Banner" class="mx-auto block h-12 sm:h-16">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<button id="theme-toggle" aria-label="Toggle theme" class="fixed bottom-4 right-4 z-50 p-3 rounded-full bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 dark:focus:ring-offset-gray-900 focus:ring-indigo-500 transition-all duration-200 ease-in-out">
|
||||
<span>🌓</span>
|
||||
</button>
|
||||
|
||||
<main class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12 flex-grow w-full max-w-7xl">
|
||||
|
||||
{% if initialization and initialization.in_progress %}
|
||||
<div class="alert-banner mb-8 p-4 rounded-lg bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 text-gray-700 dark:text-gray-300 shadow-md">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
<svg class="h-5 w-5 text-blue-500 animate-spin" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium">Initialization in progress...</p>
|
||||
<p class="text-sm mt-1">You can view logs below while waiting. The web interface will update automatically when ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if CF_ZONE_ID_CONFIGURED and relevant_zone_name_for_tld_policy and not tld_policy_exists %}
|
||||
<div id="tld-policy-alert" class="alert-banner mb-8 p-4 rounded-lg bg-amber-100 dark:bg-yellow-900/40 border-l-4 border-amber-500 dark:border-yellow-500 text-gray-800 dark:text-yellow-100 shadow-md">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-amber-500 dark:text-yellow-400 mt-0.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h4 class="font-semibold text-md text-amber-700 dark:text-yellow-200 mb-1">Best Practice: Secure Your Root Domain ({{ relevant_zone_name_for_tld_policy | e }})</h4>
|
||||
<div class="text-sm text-amber-700 dark:text-yellow-100 space-y-1">
|
||||
<p>
|
||||
An Access Policy for <strong>*.{{ relevant_zone_name_for_tld_policy | e }}</strong> is not detected.
|
||||
Creating one secures all subdomains by default, requiring authentication unless a specific bypass rule exists.
|
||||
</p>
|
||||
<p class="mb-3">
|
||||
This is highly recommended to prevent accidental exposure of new or unconfigured services.
|
||||
</p>
|
||||
</div>
|
||||
{% if account_email_for_tld %}
|
||||
<div class="mt-3">
|
||||
<form id="create-tld-policy-form" action="{{ url_for('create_default_tld_policy') }}" method="POST" onsubmit="return confirm('This will create an Access Application for *.{{ relevant_zone_name_for_tld_policy | e }} allowing access for {{ account_email_for_tld | e }}. Any unauthenticated access to subdomains of {{ relevant_zone_name_for_tld_policy | e }} (not covered by other specific Access policies) will be blocked and require login by {{ account_email_for_tld | e }}. Are you sure?');" class="protocol-aware-form">
|
||||
<input type="hidden" name="zone_name" value="{{ relevant_zone_name_for_tld_policy | e }}">
|
||||
<input type="hidden" name="target_email" value="{{ account_email_for_tld | e }}">
|
||||
<button type="submit" class="px-4 py-1.5 rounded bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-xs shadow-md transition-colors duration-150 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-offset-gray-900">
|
||||
Create Default *.{{ relevant_zone_name_for_tld_policy | e }} Policy
|
||||
</button>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1 italic">(Policy will allow: {{ account_email_for_tld | e }} and deny others)</p>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
<p class="italic">
|
||||
Could not automatically determine your Cloudflare account email to pre-configure the policy.
|
||||
Please ensure your API token has 'Account - Account Settings - Read' permission.
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
You can still create this policy manually in the Cloudflare dashboard for <strong>*.{{ relevant_zone_name_for_tld_policy | e }}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="reconciliation-status" class="mb-8"></div>
|
||||
<div id="reconciliation-status-message" class="text-secondary mb-1" style="font-size: 0.85rem;"></div>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden mb-8 sm:mb-12 transition-shadow duration-300 hover:shadow-xl">
|
||||
<div class="px-6 py-5 sm:px-8 sm:py-6">
|
||||
<h2 class="text-2xl sm:text-3xl font-semibold mb-6 text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
Tunnel & Agent Status
|
||||
</h2>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-semibold mb-3 text-gray-600 dark:text-gray-400">Cloudflare API & Tunnel Setup</h3>
|
||||
{% set status_box_class = 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' %}
|
||||
{% if tunnel_state.get('error') and not external_cloudflared %}
|
||||
{% set status_box_class = 'border-red-500 bg-red-50 dark:bg-red-900/20' %}
|
||||
{% elif (tunnel_state.get('id') and tunnel_state.get('token')) or (external_cloudflared and tunnel_state.get('id')) %}
|
||||
{% set status_box_class = 'border-green-500 bg-green-50 dark:bg-green-900/20' %}
|
||||
{% endif %}
|
||||
<div class="status-box p-4 rounded-md border-l-4 {{ status_box_class }} text-gray-700 dark:text-gray-300 text-sm">
|
||||
<p><strong>API Status:</strong> {{ tunnel_state.status_message }}</p>
|
||||
{% if tunnel_state.get('error') %}
|
||||
<div class="mt-3">
|
||||
<strong class="block mb-1">Error Details:</strong>
|
||||
<pre class="bg-gray-100 dark:bg-gray-900/50 border border-gray-300 dark:border-gray-600 rounded p-3 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-words shadow-inner">{{ tunnel_state.error }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<h4 class="text-md font-semibold mb-3 text-gray-600 dark:text-gray-400">Tunnel Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><strong class="inline-block w-36">Desired Name:</strong> <code class="bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs">{{ tunnel_state.name }}</code></p>
|
||||
<p><strong class="inline-block w-36">Tunnel ID:</strong> <code class="bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs">{{ tunnel_state.id if tunnel_state.id else 'N/A' }}</code></p>
|
||||
{% if not external_cloudflared %}
|
||||
<div>
|
||||
<strong class="inline-block w-36 align-top pt-1">Tunnel Token:</strong>
|
||||
<pre class="inline-block bg-gray-100 dark:bg-gray-900/50 border border-gray-300 dark:border-gray-600 rounded p-2 text-xs font-mono overflow-x-auto whitespace-pre-wrap break-words align-top shadow-inner max-w-full mt-1 sm:mt-0">{{ display_token }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3 text-gray-600 dark:text-gray-400">Agent Control</h3>
|
||||
{% if agent_state.last_action_status %}
|
||||
{% set action_box_class = 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' %}
|
||||
{% if 'Error:' in agent_state.last_action_status %}
|
||||
{% set action_box_class = 'border-red-500 bg-red-50 dark:bg-red-900/20' %}
|
||||
{% elif 'Warning:' in agent_state.last_action_status %}
|
||||
{% set action_box_class = 'border-amber-500 bg-amber-50 dark:bg-amber-900/20' %}
|
||||
{% endif %}
|
||||
<div class="status-box p-3 rounded-md border-l-4 {{ action_box_class }} text-gray-700 dark:text-gray-300 text-xs my-4">
|
||||
<p><strong>Last Action:</strong> {{ agent_state.last_action_status }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 sm:gap-6 mt-2 p-4 bg-gray-50 dark:bg-gray-700/30 rounded-md">
|
||||
{% if external_cloudflared %}
|
||||
<div class="agent-status-info flex-grow space-y-1.5">
|
||||
<div class="flex items-center space-x-3">
|
||||
<strong class="text-sm font-medium flex-shrink-0 w-28">External Mode:</strong>
|
||||
<span class="status-indicator inline-block flex-shrink-0 w-3 h-3 rounded-full bg-blue-500 animate-pulse-fast shadow-sm"></span>
|
||||
<strong class="text-sm font-semibold capitalize text-blue-600 dark:text-blue-400">Using External Cloudflared</strong>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<strong class="font-medium text-gray-600 dark:text-gray-400 flex-shrink-0 w-28">External ID:</strong>
|
||||
<code class="ml-3 bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs align-middle truncate" title="{{ external_tunnel_id }}">{{ external_tunnel_id }}</code>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
<p class="italic">External cloudflared mode enabled. DockFlare manages tunnel configuration and DNS records, but not the agent container.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="agent-status-info flex-grow space-y-1.5">
|
||||
<div class="flex items-center space-x-3">
|
||||
<strong class="text-sm font-medium flex-shrink-0 w-28">Agent Status:</strong>
|
||||
{% set indicator_class = 'bg-gray-400' %}
|
||||
{% if agent_state.container_status == 'running' %} {% set indicator_class = 'bg-green-500 animate-pulse-fast' %}
|
||||
{% elif agent_state.container_status in ['exited', 'dead', 'not_found'] %} {% set indicator_class = 'bg-red-500' %}
|
||||
{% elif agent_state.container_status == 'docker_unavailable' %} {% set indicator_class = 'bg-gray-500' %}
|
||||
{% elif agent_state.container_status in ['created', 'paused', 'restarting'] %} {% set indicator_class = 'bg-amber-500' %}
|
||||
{% endif %}
|
||||
<span class="status-indicator inline-block flex-shrink-0 w-3 h-3 rounded-full {{ indicator_class }} shadow-sm"></span>
|
||||
{% set status_text_class = 'text-gray-600 dark:text-gray-400' %}
|
||||
{% if agent_state.container_status=='running' %} {% set status_text_class = 'text-green-600 dark:text-green-400' %}
|
||||
{% elif agent_state.container_status in ['exited', 'dead', 'not_found', 'docker_unavailable'] %} {% set status_text_class = 'text-red-600 dark:text-red-400' %}
|
||||
{% elif agent_state.container_status in ['created', 'paused', 'restarting'] %} {% set status_text_class = 'text-amber-600 dark:text-amber-400' %}
|
||||
{% endif %}
|
||||
<strong class="text-sm font-semibold capitalize {{ status_text_class }}">{{ agent_state.container_status.replace('_',' ') if agent_state.container_status else 'Unknown' }}</strong>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<strong class="font-medium text-gray-600 dark:text-gray-400 flex-shrink-0 w-28">Agent Name:</strong>
|
||||
<code class="ml-3 bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs align-middle truncate" title="{{ cloudflared_container_name }}">{{ cloudflared_container_name }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-action-button w-full sm:w-auto flex-shrink-0">
|
||||
{% set base_button_style = 'w-full sm:w-auto inline-flex items-center justify-center px-5 py-2 rounded-md text-white font-semibold shadow-md transition-all duration-200 ease-in-out transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none' %}
|
||||
{% set start_button_style = 'bg-green-600 hover:bg-green-700 focus:ring-green-500 disabled:bg-green-300 dark:disabled:bg-green-800' %}
|
||||
{% set stop_button_style = 'bg-red-600 hover:bg-red-700 focus:ring-red-500 disabled:bg-red-300 dark:disabled:bg-red-800' %}
|
||||
{% set is_startable = tunnel_state.get('id') and tunnel_state.get('token') and docker_available %}
|
||||
{% set is_stoppable = docker_available %}
|
||||
{% if agent_state.container_status=='running' %}
|
||||
<form action="{{ url_for('stop_tunnel') }}" method="post" class="inline-block w-full sm:w-auto protocol-aware-form">
|
||||
<button type="submit" class="{{ base_button_style }} {{ stop_button_style }}" {{ 'disabled' if not is_stoppable else '' }}>Stop Agent</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="{{ url_for('start_tunnel') }}" method="post" class="inline-block w-full sm:w-auto protocol-aware-form">
|
||||
<button type="submit" class="{{ base_button_style }} {{ start_button_style }}" {{ 'disabled' if not is_startable else '' }}>Start Agent</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden mb-8 sm:mb-12 transition-shadow duration-300 hover:shadow-xl">
|
||||
<div class="px-6 py-5 sm:px-8 sm:py-6">
|
||||
<h2 class="text-2xl sm:text-3xl font-semibold mb-6 text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
Managed Ingress Rules
|
||||
</h2>
|
||||
{% if rules %}
|
||||
<div class="overflow-x-auto -mx-6 sm:-mx-8">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Hostname</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Service Target</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Container</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Delete Scheduled (UTC)</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for hostname, details in rules.items()|sort %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-150 ease-in-out">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="https://{{ hostname }}" target="_blank" rel="noopener noreferrer" title="Open https://{{ hostname }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-200 hover:underline">
|
||||
{{ hostname }}
|
||||
</a>
|
||||
{% if hostname.startswith('*.') %}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-800/30 dark:text-blue-300">wildcard</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ details.service }}</code></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% set status_text_class = 'text-orange-600 dark:text-orange-400' if 'pending' in details.status else 'text-green-600 dark:text-green-400' %}
|
||||
{% set status_bg_class = 'bg-orange-100 dark:bg-orange-900/30' if 'pending' in details.status else 'bg-green-100 dark:bg-green-900/30' %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ status_bg_class }} {{ status_text_class }}">{{ details.status.replace('_',' ') }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><code class="text-sm text-gray-700 dark:text-gray-300 font-mono" title="{{ details.container_id if details.container_id else 'N/A' }}">{{ details.container_id[:12] if details.container_id else 'N/A' }}</code></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if details.status=='pending_deletion' and details.delete_at %}
|
||||
<div data-delete-at="{{ details.delete_at.isoformat() }}">
|
||||
<span class="absolute-time">{{ details.delete_at.strftime('%H:%M %d-%b-%Y') }}</span>
|
||||
<span class="countdown-timer block text-xs text-gray-500 dark:text-gray-400">(calculating...)</span>
|
||||
</div>
|
||||
{% else %} N/A {% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<form action="{{ url_for('force_delete_rule', hostname=hostname) }}" method="post" onsubmit="return confirm('Are you sure you want to force delete the rule and DNS record for {{ hostname }} immediately? This bypasses the grace period.');" class="protocol-aware-form">
|
||||
<button type="submit" class="px-2 py-1 rounded text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 hover:text-red-800 dark:hover:text-red-200 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-transparent disabled:text-gray-400" {{ 'disabled' if not docker_available else '' }}>Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-center text-gray-500 dark:text-gray-400 py-8">No ingress rules are currently being managed.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden mb-8 sm:mb-12 transition-shadow duration-300 hover:shadow-xl">
|
||||
<div class="px-6 py-5 sm:px-8 sm:py-6">
|
||||
<h2 class="text-2xl sm:text-3xl font-semibold mb-6 text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
All Cloudflare Tunnels on Account
|
||||
</h2>
|
||||
{% if CF_ACCOUNT_ID_CONFIGURED %}
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Displaying tunnels for Cloudflare Account ID: <code class="bg-gray-200 dark:bg-gray-700 text-indigo-700 dark:text-indigo-300 px-1.5 py-0.5 rounded font-mono text-xs">{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.
|
||||
<br>
|
||||
<em>This list shows all tunnels found on the account, not just the one managed by this DockFlare instance. Click the '+' icon next to a tunnel to view its associated DNS records.</em>
|
||||
</p>
|
||||
{% if all_account_tunnels is defined and all_account_tunnels %}
|
||||
<div class="overflow-x-auto -mx-6 sm:-mx-8">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th scope="col" class="px-3 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider w-12">+/-</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tunnel Name</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tunnel ID</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Created At</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Connections</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for tunnel in all_account_tunnels %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors duration-150 ease-in-out">
|
||||
<td class="px-3 py-4 whitespace-nowrap text-sm text-center">
|
||||
<button type="button" class="tunnel-dns-toggle p-1 rounded-full text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-200 hover:bg-indigo-100 dark:hover:bg-indigo-700/50 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
data-tunnel-id="{{ tunnel.id | e }}"
|
||||
aria-expanded="false"
|
||||
aria-controls="dns-records-{{ tunnel.id | e }}"
|
||||
title="Toggle DNS records">
|
||||
<svg class="w-5 h-5 expand-icon transition-transform duration-200 ease-in-out" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path></svg>
|
||||
<svg class="w-5 h-5 collapse-icon hidden transition-transform duration-200 ease-in-out" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"></path></svg>
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">{{ tunnel.name | e }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap"><code class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ tunnel.id | e }}</code></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="status-text status-{{ tunnel.status | lower | e }} px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{% if tunnel.status | lower == 'healthy' %} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||
{% elif tunnel.status | lower == 'degraded' %} bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
|
||||
{% elif tunnel.status | lower == 'down' %} bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
|
||||
{% else %} bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 {% endif %}">
|
||||
{{ tunnel.status | capitalize | e }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if tunnel.created_at %}{{ tunnel.created_at.split('T')[0] | e }}{% else %}N/A{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if tunnel.connections and tunnel.connections is iterable and not tunnel.connections is string %}
|
||||
{% for conn in tunnel.connections %}<span class="connection-badge">{{ conn.colo_name | e }}</span>{% endfor %}
|
||||
{% if tunnel.connections | length > 0 %}({{ tunnel.connections | length }} total){% else %}(0 total){% endif %}
|
||||
{% elif tunnel.connections %}{{ tunnel.connections | e }}{% else %}None{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="dns-records-row hidden bg-gray-100 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<td colspan="6">
|
||||
<div id="dns-records-{{ tunnel.id | e }}" class="p-4 text-sm space-y-2">
|
||||
<p class="text-gray-500 dark:text-gray-400 italic">Click the '+' button to load DNS records for this tunnel.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% elif all_account_tunnels is defined %}
|
||||
<p class="text-center text-gray-500 dark:text-gray-400 py-8">No Cloudflare Tunnels found for Account ID: <code>{{ ACCOUNT_ID_FOR_DISPLAY | e }}</code>.</p>
|
||||
<p class="text-center text-sm text-gray-500 dark:text-gray-400"><em>This could also mean an error occurred fetching them (check DockFlare logs). Ensure your CF_API_TOKEN has 'Account:Cloudflare Tunnel:Read' permission for the account level.</em></p>
|
||||
{% else %}
|
||||
<p class="warning-text text-center py-8">Could not retrieve tunnel information (<code>all_account_tunnels</code> variable not available in template).</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="warning-text text-center py-8">The <code>CF_ACCOUNT_ID</code> environment variable is not configured. This section cannot be displayed.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden mb-8 sm:mb-12 transition-shadow duration-300 hover:shadow-xl">
|
||||
<div class="px-6 py-5 sm:px-8 sm:py-6">
|
||||
<h2 class="text-2xl sm:text-3xl font-semibold mb-4 text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
Real-time Activity Logs
|
||||
</h2>
|
||||
<pre id="log-output" aria-live="polite" class="h-96 overflow-y-scroll bg-gray-900 dark:bg-black text-gray-300 dark:text-gray-300 border border-gray-700 dark:border-gray-600 rounded-md p-4 font-mono text-xs leading-relaxed shadow-inner">Connecting to log stream...</pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="text-center py-6 mt-auto text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<p class="mb-1"><a href="https://github.com/ChrispyBacon-dev/DockFlare" target="_blank" rel="noopener noreferrer" class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-200 hover:underline">Dockflare on GitHub</a></p>
|
||||
<p class="mb-1">Enjoying DockFlare? Consider supporting the project!</p>
|
||||
<p><a href="https://github.com/sponsors/ChrispyBacon-dev" target="_blank" rel="noopener noreferrer" class="inline-flex items-center text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-pink-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" /></svg>Sponsor ChrispyBacon-dev on GitHub</a></p>
|
||||
<p>DockFlare v1.4 (stable)</p>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const maxLogLines = 250;let initialConnectMessageCleared = false;let activeLogSource = null;let eventSourceHealthCheck = null;let pingInterval = null;
|
||||
function addLogLine(message, type = 'log') {const logOutput = document.getElementById('log-output');if (!logOutput) { console.error("Log output element not found when trying to add line."); return; }if (!initialConnectMessageCleared && logOutput.textContent.includes('Connecting to log stream...')) {logOutput.textContent = ''; initialConnectMessageCleared = true;}const newLogLine = document.createElement('div');newLogLine.textContent = message;newLogLine.classList.add('mb-1');if (type === 'status') newLogLine.classList.add('log-status');else if (type === 'error') newLogLine.classList.add('log-status', 'error');else if (type === 'connected') newLogLine.classList.add('log-status', 'connected');const isScrolledToBottom = logOutput.scrollHeight - logOutput.clientHeight <= logOutput.scrollTop + 10;logOutput.appendChild(newLogLine);while (logOutput.childNodes.length > maxLogLines) logOutput.removeChild(logOutput.firstChild);if (isScrolledToBottom) logOutput.scrollTop = logOutput.scrollHeight;}
|
||||
function connectEventSource() {const logOutput = document.getElementById('log-output');if (!logOutput) { console.error("Log output element not found for EventSource setup."); return; }if (!window.EventSource) {console.error("EventSource not supported");logOutput.textContent = "Browser doesn't support Server-Sent Events."; return;}if (activeLogSource) { try { activeLogSource.close(); } catch (e) { console.error("Error closing existing log stream:", e); } activeLogSource = null; }const currentProtocol = window.location.protocol; const currentHost = window.location.host;const timestampParam = Date.now();const streamUrl = `${currentProtocol}//${currentHost}/stream-logs?t=${timestampParam}`;try {activeLogSource = new EventSource(streamUrl);let connectionTimeout;const resetConnectionTimeout = () => {if (connectionTimeout) clearTimeout(connectionTimeout);connectionTimeout = setTimeout(() => {if (activeLogSource) {activeLogSource.close(); activeLogSource = null;addLogLine("--- Log stream connection timeout. Reconnecting... ---", 'error');setTimeout(connectEventSource, 2000);}}, 10000);};resetConnectionTimeout();activeLogSource.onopen = function() {if (connectionTimeout) clearTimeout(connectionTimeout);addLogLine("--- Log stream connected ---", 'connected');};activeLogSource.onmessage = function(event) {resetConnectionTimeout(); if (event.data === "heartbeat") { return; }addLogLine(event.data, 'log');};let retryAttempt = 0;activeLogSource.onerror = function(err) {if (connectionTimeout) clearTimeout(connectionTimeout);addLogLine("--- Log stream connection error. Retrying... ---", 'error');if (activeLogSource) { activeLogSource.close(); activeLogSource = null; }retryAttempt++; const delay = Math.min(5000 * Math.pow(1.5, retryAttempt - 1), 30000);setTimeout(connectEventSource, delay);};} catch (e) {addLogLine("--- Failed to establish log stream connection ---", 'error'); setTimeout(connectEventSource, 5000);}if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck);eventSourceHealthCheck = setInterval(() => {if (!activeLogSource || activeLogSource.readyState === 2) {connectEventSource();}}, 30000);window.addEventListener('beforeunload', function() {if (activeLogSource) activeLogSource.close(); if (eventSourceHealthCheck) clearInterval(eventSourceHealthCheck);});}
|
||||
function checkEnvironment() {const currentProtocol = window.location.protocol; const currentHost = window.location.host;const debugUrl = `${currentProtocol}//${currentHost}/debug`;fetch(debugUrl).then(response => response.json()).then(data => { alert("Env debug info logged to console."); }).catch(error => console.error("Failed to fetch environment info:", error));}
|
||||
function formatTimeDifference(diffMillis) {const totalSeconds = Math.round(Math.abs(diffMillis / 1000)); if (totalSeconds < 60) return diffMillis >= 0 ? 'in <1m' : '<1m ago';const days = Math.floor(totalSeconds / (3600 * 24)); const hours = Math.floor((totalSeconds % (3600 * 24)) / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60);let parts = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0 || (days === 0 && hours === 0)) parts.push(`${minutes}m`);const timeString = parts.join(' '); return diffMillis >= 0 ? `in ${timeString}` : `${timeString} ago`;}
|
||||
function updateCountdowns() {const countdownElements = document.querySelectorAll('.countdown-timer'); if (!countdownElements.length) return;const now = new Date();countdownElements.forEach(span => {const td = span.closest('td[data-delete-at]'); if (!td) return;const deleteAtISO = td.dataset.deleteAt; if (!deleteAtISO) return;try {const targetDate = new Date(deleteAtISO); if (isNaN(targetDate.getTime())) throw new Error("Invalid date parsed");const diff = targetDate - now; span.textContent = `(${formatTimeDifference(diff)})`;if (diff < 0) span.classList.add('text-red-500'); else span.classList.remove('text-red-500');} catch (e) { span.textContent = "(invalid date)"; }});}
|
||||
function startServerPing() {if (pingInterval) clearInterval(pingInterval);pingInterval = setInterval(() => {const currentProtocol = window.location.protocol; const currentHost = window.location.host;const pingUrl = `${currentProtocol}//${currentHost}/ping?t=${new Date().getTime()}`;fetch(pingUrl).then(response => response.ok ? response.json() : Promise.reject(`Ping failed: ${response.status}`)).then(data => {}).catch(error => {});}, 30000);}
|
||||
function updateReconciliationStatus() {const currentProtocol = window.location.protocol;const currentHost = window.location.host;const statusUrl = `${currentProtocol}//${currentHost}/reconciliation-status?t=${new Date().getTime()}`;fetch(statusUrl).then(response => response.json()).then(data => {const statusElement = document.getElementById('reconciliation-status');const messageElement = document.getElementById('reconciliation-status-message');if (!statusElement || !messageElement) {return;}if (data.status) {messageElement.textContent = data.status;messageElement.style.display = data.in_progress ? 'block' : 'none';}if (data.in_progress) {statusElement.innerHTML = `<div class="alert-banner p-4 rounded-lg bg-amber-50 dark:bg-amber-900/30 border-l-4 border-amber-500 text-gray-700 dark:text-gray-300 shadow-md"><div class="flex items-center"><div class="flex-shrink-0 mr-3"><svg class="h-5 w-5 text-amber-500 animate-spin" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M12 6v6l4 2"></path></svg></div><div><p class="font-medium">Reconciliation: ${data.progress}%</p><p class="text-sm mt-1">Processing ${data.processed_items} of ${data.total_items} items...</p></div></div></div>`;} else if (statusElement.innerHTML.includes('Reconciliation')) {statusElement.innerHTML = `<div class="alert-banner p-4 rounded-lg bg-green-50 dark:bg-green-900/30 border-l-4 border-green-500 text-gray-700 dark:text-gray-300 shadow-md"><div class="flex items-center"><div class="flex-shrink-0 mr-3"><svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"></path></svg></div><div><p class="font-medium">Reconciliation complete</p></div></div></div>`;setTimeout(() => {if (statusElement.innerHTML.includes('Reconciliation complete')) {statusElement.innerHTML = '';if (messageElement) {messageElement.style.display = 'none';}}}, 5000);}}).catch(err => {});}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const currentProtocol = window.location.protocol;const currentHost = window.location.host;
|
||||
document.querySelectorAll('form.protocol-aware-form').forEach(form => {if (form.getAttribute('action')) {let actionUrl = form.getAttribute('action');if (actionUrl.startsWith('/')) {actionUrl = `${currentProtocol}//${currentHost}${actionUrl}`; form.setAttribute('action', actionUrl);} else if (actionUrl.startsWith('http')) {try {const parsedUrl = new URL(actionUrl);if (parsedUrl.host === currentHost && parsedUrl.protocol !== currentProtocol) {parsedUrl.protocol = currentProtocol; form.setAttribute('action', parsedUrl.toString());}} catch (e) {}}}});
|
||||
document.querySelectorAll('a[href]').forEach(link => {const href = link.getAttribute('href');if (href && (href.startsWith('/') || (href.startsWith('http') && new URL(href, window.location.origin).host === currentHost))) {try {const url = new URL(href, window.location.origin);if (url.host === currentHost && url.protocol !== currentProtocol) {url.protocol = currentProtocol;link.setAttribute('href', url.toString());}} catch (e) {}}});
|
||||
updateCountdowns();const countdownInterval = setInterval(updateCountdowns, 30000);connectEventSource();
|
||||
const themeToggleButton = document.getElementById('theme-toggle');const htmlElement = document.documentElement;
|
||||
if (themeToggleButton) {const themeToggleIcon = themeToggleButton.querySelector('span');const setTheme = (theme) => {if (theme !== 'light' && theme !== 'dark') { return; }localStorage.setItem('theme', theme);if (theme === 'dark') {htmlElement.classList.add('dark'); if (themeToggleIcon) themeToggleIcon.textContent = '☀️';} else {htmlElement.classList.remove('dark'); if (themeToggleIcon) themeToggleIcon.textContent = '🌓';}};const toggleTheme = () => {const isCurrentlyDark = htmlElement.classList.contains('dark');const currentTheme = isCurrentlyDark ? 'dark' : 'light'; const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; setTheme(newTheme);};themeToggleButton.addEventListener('click', toggleTheme);const initTheme = () => {const savedTheme = localStorage.getItem('theme'); const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;if (savedTheme === 'dark' || savedTheme === 'light') setTheme(savedTheme);else setTheme(prefersDark ? 'dark' : 'light');};initTheme();}
|
||||
updateReconciliationStatus();setInterval(updateReconciliationStatus, 2000);
|
||||
|
||||
// --- START: New JavaScript for Tunnel DNS Toggle ---
|
||||
document.querySelectorAll('.tunnel-dns-toggle').forEach(button => {
|
||||
button.addEventListener('click', async function() {
|
||||
const tunnelId = this.dataset.tunnelId;
|
||||
const tunnelDetailsRow = this.closest('tr');
|
||||
const dnsRecordsDisplayRow = tunnelDetailsRow.nextElementSibling;
|
||||
const targetDivId = this.getAttribute('aria-controls');
|
||||
const targetDiv = document.getElementById(targetDivId);
|
||||
const isExpanded = this.getAttribute('aria-expanded') === 'true';
|
||||
const expandIcon = this.querySelector('.expand-icon');
|
||||
const collapseIcon = this.querySelector('.collapse-icon');
|
||||
|
||||
if (!dnsRecordsDisplayRow || !dnsRecordsDisplayRow.classList.contains('dns-records-row')) {
|
||||
console.error(`Could not find the dedicated DNS records row for tunnel ${tunnelId}. Button was in:`, tunnelDetailsRow, "Next sibling is:", dnsRecordsDisplayRow);
|
||||
return;
|
||||
}
|
||||
if (!targetDiv) {
|
||||
console.error(`Could not find targetDiv with ID ${targetDivId} for tunnel ${tunnelId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
dnsRecordsDisplayRow.classList.add('hidden');
|
||||
this.setAttribute('aria-expanded', 'false');
|
||||
if (expandIcon) expandIcon.classList.remove('hidden');
|
||||
if (collapseIcon) collapseIcon.classList.add('hidden');
|
||||
} else {
|
||||
this.setAttribute('aria-expanded', 'true');
|
||||
if (expandIcon) expandIcon.classList.add('hidden');
|
||||
if (collapseIcon) collapseIcon.classList.remove('hidden');
|
||||
|
||||
if (targetDiv.dataset.loaded !== 'true' || targetDiv.dataset.loaded === 'error') {
|
||||
targetDiv.innerHTML = '<p class="text-gray-500 dark:text-gray-400 italic animate-pulse p-2">Loading DNS records...</p>';
|
||||
dnsRecordsDisplayRow.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const currentProtocol = window.location.protocol;
|
||||
const currentHost = window.location.host;
|
||||
const fetchUrl = `${currentProtocol}//${currentHost}/tunnel-dns-records/${encodeURIComponent(tunnelId)}?t=${Date.now()}`;
|
||||
|
||||
const response = await fetch(fetchUrl);
|
||||
if (!response.ok) {
|
||||
let errorDetail = `HTTP error ${response.status}`;
|
||||
try { const errorData = await response.json(); errorDetail = errorData.error || errorData.message || errorDetail; } catch (e) {}
|
||||
throw new Error(errorDetail);
|
||||
}
|
||||
const data = await response.json();
|
||||
const currentTargetDiv = document.getElementById(`dns-records-${tunnelId}`);
|
||||
if (!currentTargetDiv) {
|
||||
console.error("PANIC: Target DIV disappeared or was wrong during fetch for tunnel " + tunnelId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.dns_records && data.dns_records.length > 0) {
|
||||
let dnsHtml = '<ul class="list-none pl-4 space-y-1.5">';
|
||||
data.dns_records.forEach(record => {
|
||||
const recordUrl = `https://${record.name}`;
|
||||
dnsHtml += `<li class="text-gray-700 dark:text-gray-300"><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline-block mr-2 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg><a href="${recordUrl}" target="_blank" rel="noopener noreferrer" class="hover:text-indigo-500 dark:hover:text-indigo-300 hover:underline">${record.name}</a></li>`;
|
||||
});
|
||||
dnsHtml += '</ul>';
|
||||
currentTargetDiv.innerHTML = dnsHtml;
|
||||
currentTargetDiv.dataset.loaded = 'true';
|
||||
} else if (data.message) {
|
||||
currentTargetDiv.innerHTML = `<p class="text-gray-600 dark:text-gray-400 italic p-2">${data.message}</p>`;
|
||||
currentTargetDiv.dataset.loaded = 'info';
|
||||
} else {
|
||||
currentTargetDiv.innerHTML = '<p class="text-gray-600 dark:text-gray-400 italic p-2">No CNAME DNS records found pointing to this tunnel in the configured zones.</p>';
|
||||
currentTargetDiv.dataset.loaded = 'true';
|
||||
}
|
||||
} catch (error) {
|
||||
const errorTargetDiv = document.getElementById(`dns-records-${tunnelId}`);
|
||||
if (errorTargetDiv) {
|
||||
errorTargetDiv.innerHTML = `<p class="text-red-500 dark:text-red-400 p-2">Error loading DNS records: ${error.message}</p>`;
|
||||
errorTargetDiv.dataset.loaded = 'error';
|
||||
}
|
||||
}
|
||||
}
|
||||
dnsRecordsDisplayRow.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
// --- END: New JavaScript for Tunnel DNS Toggle ---
|
||||
|
||||
});
|
||||
startServerPing();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -112,7 +112,7 @@
|
|||
|
||||
<div style="position: fixed; top: 0; left: 0; z-index: 50; pointer-events: none;">
|
||||
<div class="fixed top-6 left-[-38px] sm:top-7 sm:left-[-35px] z-50 transform -rotate-45 bg-indigo-600 dark:bg-indigo-500 text-white dark:text-white text-xs font-semibold uppercase tracking-wider text-center py-1 w-36 shadow-md">
|
||||
stable 1.4.0
|
||||
stable 1.5
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -488,7 +488,7 @@
|
|||
<p class="mb-1"><a href="https://github.com/ChrispyBacon-dev/DockFlare" target="_blank" rel="noopener noreferrer" class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-200 hover:underline">Dockflare on GitHub</a></p>
|
||||
<p class="mb-1">Enjoying DockFlare? Consider supporting the project!</p>
|
||||
<p><a href="https://github.com/sponsors/ChrispyBacon-dev" target="_blank" rel="noopener noreferrer" class="inline-flex items-center text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-pink-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" clip-rule="evenodd" /></svg>Sponsor ChrispyBacon-dev on GitHub</a></p>
|
||||
<p>DockFlare v1.4 (stable)</p>
|
||||
<p>DockFlare v1.5 (stable)</p>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue