This commit is contained in:
2026-04-01 20:02:57 +11:00
parent 1d1fb2db55
commit 5fee444db9
3 changed files with 286 additions and 0 deletions
+95
View File
@@ -1,5 +1,11 @@
from django.views.generic import TemplateView
from django.shortcuts import render
from django.http import JsonResponse
from django.views import View
import json
import logging
logger = logging.getLogger(__name__)
class MiniAppsListView(TemplateView):
@@ -34,6 +40,14 @@ class MiniAppsListView(TemplateView):
'icon': 'fas fa-chart-line',
'color': '#27ae60'
},
{
'name': 'Nginx IP Ban',
'description': 'Manage the nginx ingress block list — view, add, and remove banned IPs and CIDR ranges directly from the Kubernetes ConfigMap.',
'url': 'mini-apps-ip-ban',
'thumbnail': 'https://images.unsplash.com/photo-1614064641938-3bbee52942c7?w=400&h=300&fit=crop',
'icon': 'fas fa-ban',
'color': '#e74c3c'
},
{
'name': 'Weather Dashboard',
'description': 'Real-time weather information with beautiful visualizations and forecasts.',
@@ -73,3 +87,84 @@ class FIREPlanningView(TemplateView):
context = super().get_context_data(**kwargs)
context['page_title'] = 'FIRE Planning Calculator'
return context
# ---------------------------------------------------------------------------
# Nginx IP Ban mini app
# ---------------------------------------------------------------------------
_CONFIGMAP_NAME = 'ingress-nginx-controller'
_CONFIGMAP_NS = 'ingress-nginx'
_CONFIGMAP_KEY = 'block-cidrs-manual'
def _k8s_v1():
"""Return a CoreV1Api client, preferring in-cluster then local kubeconfig."""
from kubernetes import client, config
try:
config.load_incluster_config()
except config.ConfigException:
config.load_kube_config()
return client.CoreV1Api()
def _read_blocked_ips() -> list[str]:
v1 = _k8s_v1()
cm = v1.read_namespaced_config_map(_CONFIGMAP_NAME, _CONFIGMAP_NS)
raw = (cm.data or {}).get(_CONFIGMAP_KEY, '')
return [ip.strip() for ip in raw.split(',') if ip.strip()]
def _write_blocked_ips(ips: list[str]) -> None:
v1 = _k8s_v1()
from kubernetes.client import V1ConfigMap
body = V1ConfigMap(data={_CONFIGMAP_KEY: ','.join(ips)})
v1.patch_namespaced_config_map(_CONFIGMAP_NAME, _CONFIGMAP_NS, body)
class NginxIPBanView(View):
template = 'links/mini_apps/ip_ban.html'
def get(self, request):
error = None
blocked = []
try:
blocked = _read_blocked_ips()
except Exception as exc:
logger.error('ip_ban: failed to read ConfigMap: %s', exc)
error = str(exc)
return render(request, self.template, {'blocked': blocked, 'error': error})
def post(self, request):
action = request.POST.get('action')
try:
blocked = _read_blocked_ips()
if action == 'add':
raw = request.POST.get('ips', '')
# Accept comma- or newline-separated entries
new_ips = [
ip.strip()
for part in raw.replace('\n', ',').split(',')
for ip in [part.strip()]
if ip
]
added = 0
for ip in new_ips:
if ip not in blocked:
blocked.append(ip)
added += 1
_write_blocked_ips(blocked)
return JsonResponse({'ok': True, 'blocked': blocked, 'added': added})
elif action == 'remove':
ip = request.POST.get('ip', '').strip()
if ip in blocked:
blocked.remove(ip)
_write_blocked_ips(blocked)
return JsonResponse({'ok': True, 'blocked': blocked})
return JsonResponse({'ok': False, 'error': 'Unknown action'}, status=400)
except Exception as exc:
logger.error('ip_ban: action=%s error=%s', action, exc)
return JsonResponse({'ok': False, 'error': str(exc)}, status=500)
+190
View File
@@ -0,0 +1,190 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}Nginx IP Ban Manager{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<a href="{% url 'mini-apps-list' %}" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-arrow-left"></i>
</a>
<div>
<h1 class="text-xl font-bold text-gray-900 flex items-center gap-2">
<i class="fas fa-ban text-red-500"></i> Nginx IP Ban Manager
</h1>
<p class="text-sm text-gray-400 mt-0.5">
Manages <code class="bg-gray-100 px-1 rounded text-xs">block-cidrs-manual</code>
in the <code class="bg-gray-100 px-1 rounded text-xs">ingress-nginx-controller</code> ConfigMap
</p>
</div>
</div>
<!-- Error banner -->
{% if error %}
<div id="error-banner" class="px-4 py-3 rounded-md text-sm border bg-red-50 text-red-800 border-red-200">
<i class="fas fa-exclamation-triangle mr-2"></i>{{ error }}
</div>
{% endif %}
<!-- AJAX feedback -->
<div id="feedback" class="hidden px-4 py-2 rounded-md text-sm border"></div>
<!-- Add IPs -->
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h2 class="text-sm font-semibold text-gray-700">
<i class="fas fa-plus-circle text-red-500 mr-1"></i> Ban IP(s) / CIDR(s)
</h2>
<p class="text-xs text-gray-400 mt-0.5">
Enter one or more IPs or CIDRs, comma- or newline-separated.
Examples: <code class="bg-gray-100 px-1 rounded">1.2.3.4</code>,
<code class="bg-gray-100 px-1 rounded">5.6.7.0/24</code>
</p>
</div>
<div class="p-6">
<textarea id="add-ips-input" rows="3"
placeholder="1.2.3.4&#10;5.6.7.0/24&#10;8.8.8.8, 9.9.9.9"
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-red-400 focus:border-transparent"></textarea>
<div class="mt-3 flex items-center gap-3">
<button id="add-btn"
class="inline-flex items-center px-5 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 transition-colors">
<i class="fas fa-ban mr-2"></i> Ban IPs
</button>
<span class="text-xs text-gray-400">Duplicates are skipped automatically.</span>
</div>
</div>
</div>
<!-- Banned IPs list -->
<div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-700">
<i class="fas fa-list text-gray-500 mr-1"></i>
Currently Banned
<span id="count-badge"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">
{{ blocked|length }}
</span>
</h2>
</div>
<div id="ip-list">
{% if blocked %}
<ul class="divide-y divide-gray-100">
{% for ip in blocked %}
<li class="flex items-center justify-between px-6 py-3 hover:bg-gray-50" data-ip="{{ ip }}">
<span class="font-mono text-sm text-gray-800">{{ ip }}</span>
<button class="remove-btn text-xs text-red-500 hover:text-red-700 font-medium transition-colors"
data-ip="{{ ip }}">
<i class="fas fa-times mr-1"></i>Remove
</button>
</li>
{% endfor %}
</ul>
{% else %}
<p id="empty-msg" class="px-6 py-8 text-sm text-gray-400 text-center">
<i class="fas fa-check-circle text-green-400 mr-2"></i>No IPs are currently banned.
</p>
{% endif %}
</div>
</div>
</div>
<script>
(function () {
const csrfToken = '{{ csrf_token }}';
const feedbackEl = document.getElementById('feedback');
const listEl = document.getElementById('ip-list');
const countBadge = document.getElementById('count-badge');
function showFeedback(msg, isError) {
feedbackEl.textContent = msg;
feedbackEl.className = isError
? 'px-4 py-2 rounded-md text-sm border bg-red-50 text-red-800 border-red-200'
: 'px-4 py-2 rounded-md text-sm border bg-green-50 text-green-800 border-green-200';
feedbackEl.classList.remove('hidden');
clearTimeout(feedbackEl._t);
feedbackEl._t = setTimeout(() => feedbackEl.classList.add('hidden'), 4000);
}
function renderList(blocked) {
countBadge.textContent = blocked.length;
if (blocked.length === 0) {
listEl.innerHTML = '<p id="empty-msg" class="px-6 py-8 text-sm text-gray-400 text-center"><i class="fas fa-check-circle text-green-400 mr-2"></i>No IPs are currently banned.</p>';
return;
}
const rows = blocked.map(ip =>
`<li class="flex items-center justify-between px-6 py-3 hover:bg-gray-50" data-ip="${escHtml(ip)}">
<span class="font-mono text-sm text-gray-800">${escHtml(ip)}</span>
<button class="remove-btn text-xs text-red-500 hover:text-red-700 font-medium transition-colors" data-ip="${escHtml(ip)}">
<i class="fas fa-times mr-1"></i>Remove
</button>
</li>`
).join('');
listEl.innerHTML = `<ul class="divide-y divide-gray-100">${rows}</ul>`;
listEl.querySelectorAll('.remove-btn').forEach(btn => btn.addEventListener('click', handleRemove));
}
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
async function post(data) {
const body = new URLSearchParams({csrfmiddlewaretoken: csrfToken, ...data});
const resp = await fetch(window.location.pathname, {method: 'POST', body});
return resp.json();
}
// Add
document.getElementById('add-btn').addEventListener('click', async () => {
const input = document.getElementById('add-ips-input');
const val = input.value.trim();
if (!val) return;
try {
const data = await post({action: 'add', ips: val});
if (data.ok) {
input.value = '';
renderList(data.blocked);
showFeedback(`✓ Banned ${data.added} new IP(s). Total: ${data.blocked.length}.`, false);
} else {
showFeedback('Error: ' + data.error, true);
}
} catch (e) {
showFeedback('Network error: ' + e, true);
}
});
// Remove (delegated for dynamically rendered rows)
listEl.addEventListener('click', handleRemoveDelegated);
async function handleRemoveDelegated(e) {
const btn = e.target.closest('.remove-btn');
if (!btn) return;
await handleRemove.call(btn, e);
}
async function handleRemove(e) {
const ip = this.dataset.ip;
if (!confirm(`Remove "${ip}" from the ban list?`)) return;
try {
const data = await post({action: 'remove', ip});
if (data.ok) {
renderList(data.blocked);
showFeedback(`✓ Removed ${ip}.`, false);
} else {
showFeedback('Error: ' + data.error, true);
}
} catch (e) {
showFeedback('Network error: ' + e, true);
}
}
// Wire up initial remove buttons
listEl.querySelectorAll('.remove-btn').forEach(btn => btn.addEventListener('click', handleRemove));
})();
</script>
{% endblock %}
+1
View File
@@ -56,6 +56,7 @@ urlpatterns = [
path('ui/mini-apps/image-gallery/', mini_apps_views.ImageGalleryView.as_view(), name='mini-apps-image-gallery'),
path('ui/mini-apps/tts/', mini_apps_views.TTSView.as_view(), name='mini-apps-tts'),
path('ui/mini-apps/fire-planning/', mini_apps_views.FIREPlanningView.as_view(), name='mini-apps-fire-planning'),
path('ui/mini-apps/ip-ban/', mini_apps_views.NginxIPBanView.as_view(), name='mini-apps-ip-ban'),
# TTS API
path('ui/tts-api/', views.generate_tts_api, name='tts-api'),