backup
This commit is contained in:
@@ -70,7 +70,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ../backend/app:/app/app:z
|
- ../backend/app:/app/app:z
|
||||||
- ../fail2ban-master:/app/fail2ban-master:ro,z
|
- ../fail2ban-master:/app/fail2ban-master:ro,z
|
||||||
- bangui-dev-data:/data
|
- ../data:/data
|
||||||
- fail2ban-dev-run:/var/run/fail2ban:ro
|
- fail2ban-dev-run:/var/run/fail2ban:ro
|
||||||
- ./fail2ban-dev-config:/config:rw
|
- ./fail2ban-dev-config:/config:rw
|
||||||
ports:
|
ports:
|
||||||
@@ -82,7 +82,7 @@ services:
|
|||||||
"--reload", "--reload-dir", "/app/app"
|
"--reload", "--reload-dir", "/app/app"
|
||||||
]
|
]
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/api/health\", timeout=4)'"]
|
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8000/api/v1/health/live\", timeout=4)'"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
start_period: 45s
|
start_period: 45s
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# BanGUI — Simulated authentication failure filter
|
|
||||||
#
|
|
||||||
# Matches lines written by Docker/simulate_failed_logins.sh
|
|
||||||
# Format: <timestamp> bangui-auth: authentication failure from <HOST>
|
|
||||||
# Jail: manual-Jail
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Definition]
|
|
||||||
|
|
||||||
failregex = ^.* bangui-auth: authentication failure from <HOST>\s*$
|
|
||||||
|
|
||||||
ignoreregex =
|
|
||||||
@@ -1,25 +1,10 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# BanGUI — Blocklist-import jail
|
|
||||||
#
|
|
||||||
# Dedicated jail for IPs banned via the BanGUI blocklist import
|
|
||||||
# feature. This is a manual-ban jail: it does not watch any log
|
|
||||||
# file. All bans are injected programmatically via
|
|
||||||
# fail2ban-client set blocklist-import banip <ip>
|
|
||||||
# which the BanGUI backend uses through its fail2ban socket
|
|
||||||
# client.
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[blocklist-import]
|
[blocklist-import]
|
||||||
|
|
||||||
enabled = true
|
enabled = false
|
||||||
# No log-based detection — only manual banip commands are used.
|
|
||||||
filter =
|
filter =
|
||||||
logpath = /dev/null
|
logpath = /dev/null
|
||||||
backend = auto
|
backend = auto
|
||||||
maxretry = 1
|
maxretry = 1
|
||||||
findtime = 1d
|
findtime = 1d
|
||||||
# Block imported IPs for 24 hours.
|
|
||||||
bantime = 86400
|
bantime = 86400
|
||||||
|
|
||||||
# Never ban the Docker bridge network or localhost.
|
|
||||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# BanGUI — Simulated authentication failure jail
|
|
||||||
#
|
|
||||||
# Watches Docker/logs/auth.log (mounted at /remotelogs/bangui)
|
|
||||||
# for lines produced by Docker/simulate_failed_logins.sh.
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[manual-Jail]
|
[manual-Jail]
|
||||||
|
|
||||||
enabled = true
|
enabled = false
|
||||||
filter = manual-Jail
|
filter = manual-Jail
|
||||||
logpath = /remotelogs/bangui/auth.log
|
logpath = /remotelogs/bangui/auth.log
|
||||||
backend = polling
|
backend = polling
|
||||||
maxretry = 3
|
maxretry = 3
|
||||||
findtime = 120
|
findtime = 120
|
||||||
bantime = 60
|
bantime = 60
|
||||||
|
|
||||||
# Never ban localhost, the Docker bridge network, or the host machine.
|
|
||||||
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
ignoreip = 127.0.0.0/8 ::1 172.16.0.0/12
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# Local overrides — not overwritten by the container init script.
|
|
||||||
# Provides banaction so all jails can resolve %(action_)s interpolation.
|
|
||||||
|
|
||||||
[DEFAULT]
|
|
||||||
banaction = iptables-multiport
|
|
||||||
banaction_allports = iptables-allports
|
|
||||||
@@ -123,7 +123,7 @@ Per-IP rate limits applied to API endpoints.
|
|||||||
| Variable | Type | Default | Description |
|
| Variable | Type | Default | Description |
|
||||||
|----------|------|---------|-------------|
|
|----------|------|---------|-------------|
|
||||||
| `BANGUI_RATE_LIMIT_BANS_PER_MINUTE` | int | `100` | Max ban/unban requests per IP per minute. |
|
| `BANGUI_RATE_LIMIT_BANS_PER_MINUTE` | int | `100` | Max ban/unban requests per IP per minute. |
|
||||||
| `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR` | int | `10` | Max blocklist import requests per IP per hour. |
|
| `BANGUI_RATE_LIMIT_BLOCKLIST_IMPORT_PER_HOUR` | int | `100` | Max blocklist import requests per IP per hour. |
|
||||||
| `BANGUI_RATE_LIMIT_CONFIG_UPDATE_PER_MINUTE` | int | `50` | Max config update requests per IP per minute. |
|
| `BANGUI_RATE_LIMIT_CONFIG_UPDATE_PER_MINUTE` | int | `50` | Max config update requests per IP per minute. |
|
||||||
|
|
||||||
**Rate limit reset mechanism:** Each limit is applied per-client IP. To bypass the blocklist import rate limit in automated tests (E2E-4), send a unique `X-Forwarded-For` header with each import request — e.g., `X-Forwarded-For: 10.0.0.99`. The header is only honoured when the client IP falls within `BANGUI_TRUSTED_PROXIES`; otherwise the real client IP is used.
|
**Rate limit reset mechanism:** Each limit is applied per-client IP. To bypass the blocklist import rate limit in automated tests (E2E-4), send a unique `X-Forwarded-For` header with each import request — e.g., `X-Forwarded-For: 10.0.0.99`. The header is only honoured when the client IP falls within `BANGUI_TRUSTED_PROXIES`; otherwise the real client IP is used.
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -64,10 +64,11 @@ print('Created .env with a generated BANGUI_SESSION_SECRET.')"; \
|
|||||||
|
|
||||||
## Start the debug stack (detached).
|
## Start the debug stack (detached).
|
||||||
## Ensures log stub files exist so fail2ban can open them on first start.
|
## Ensures log stub files exist so fail2ban can open them on first start.
|
||||||
|
## All output is logged to Docker/logs/make-up.log.
|
||||||
up: ensure-env
|
up: ensure-env
|
||||||
@mkdir -p Docker/logs
|
@mkdir -p Docker/logs
|
||||||
@touch Docker/logs/auth.log
|
@touch Docker/logs/auth.log
|
||||||
$(COMPOSE) $(COMPOSE_OPTS) up -d
|
$(COMPOSE) $(COMPOSE_OPTS) up -d 2>&1 | tee Docker/logs/make-up.log
|
||||||
|
|
||||||
## Stop the debug stack.
|
## Stop the debug stack.
|
||||||
down: ensure-env
|
down: ensure-env
|
||||||
|
|||||||
@@ -291,8 +291,11 @@ async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 3. Close HTTP session to release connections.
|
# 3. Close HTTP session to release connections.
|
||||||
await http_session.close()
|
try:
|
||||||
log.debug("http_session_closed")
|
await http_session.close()
|
||||||
|
log.debug("http_session_closed")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
log.debug("http_session_close_cancelled")
|
||||||
|
|
||||||
# 4. Shutdown external logging handler.
|
# 4. Shutdown external logging handler.
|
||||||
if _external_log_handler:
|
if _external_log_handler:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ router = APIRouter(prefix="/api/v1/setup", tags=["setup"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/",
|
"",
|
||||||
response_model=SetupStatusResponse,
|
response_model=SetupStatusResponse,
|
||||||
summary="Check whether setup has been completed",
|
summary="Check whether setup has been completed",
|
||||||
responses={
|
responses={
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ RATE_LIMIT_BANS_BAN_REQUESTS: Final[int] = 100
|
|||||||
RATE_LIMIT_BANS_UNBAN_REQUESTS: Final[int] = 100
|
RATE_LIMIT_BANS_UNBAN_REQUESTS: Final[int] = 100
|
||||||
"""Max unban requests per IP per minute."""
|
"""Max unban requests per IP per minute."""
|
||||||
|
|
||||||
RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS: Final[int] = 10
|
RATE_LIMIT_BLOCKLIST_IMPORT_REQUESTS: Final[int] = 100
|
||||||
"""Max blocklist import requests per IP per hour."""
|
"""Max blocklist import requests per IP per hour."""
|
||||||
|
|
||||||
RATE_LIMIT_CONFIG_UPDATE_REQUESTS: Final[int] = 50
|
RATE_LIMIT_CONFIG_UPDATE_REQUESTS: Final[int] = 50
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def _load_vendored_fail2ban_client() -> type[object]:
|
|||||||
|
|
||||||
return CSocket
|
return CSocket
|
||||||
except ImportError:
|
except ImportError:
|
||||||
vendor_root = Path(__file__).resolve().parents[4] / "fail2ban-master"
|
vendor_root = Path(__file__).resolve().parents[2] / "fail2ban-master"
|
||||||
if not vendor_root.is_dir():
|
if not vendor_root.is_dir():
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.db import init_db
|
from app.db import init_db
|
||||||
from app.main import _lifespan, create_app
|
from app.main import _lifespan, create_app
|
||||||
|
from app.models.server import ServerStatus
|
||||||
from app.services import setup_service
|
from app.services import setup_service
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -42,10 +43,16 @@ async def app_and_client(tmp_path: Path) -> tuple[object, AsyncClient]: # type:
|
|||||||
Yields:
|
Yields:
|
||||||
A tuple of ``(FastAPI app instance, AsyncClient)``.
|
A tuple of ``(FastAPI app instance, AsyncClient)``.
|
||||||
"""
|
"""
|
||||||
|
config_dir = tmp_path / "fail2ban"
|
||||||
|
(config_dir / "jail.d").mkdir(parents=True)
|
||||||
|
(config_dir / "filter.d").mkdir(parents=True)
|
||||||
|
(config_dir / "action.d").mkdir(parents=True)
|
||||||
|
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
database_path=str(tmp_path / "setup_cache_test.db"),
|
database_path=str(tmp_path / "setup_cache_test.db"),
|
||||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||||
session_secret="test-setup-cache-secret",
|
fail2ban_config_dir=str(config_dir),
|
||||||
|
session_secret="test-setup-cache-secret-that-is-long-enough",
|
||||||
session_duration_minutes=60,
|
session_duration_minutes=60,
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
log_level="debug",
|
log_level="debug",
|
||||||
@@ -115,7 +122,7 @@ class TestPostSetup:
|
|||||||
"/api/v1/setup",
|
"/api/v1/setup",
|
||||||
json={"master_password": "short"},
|
json={"master_password": "short"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 400
|
||||||
|
|
||||||
async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None:
|
async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None:
|
||||||
"""Setup endpoint rejects passwords missing an uppercase character."""
|
"""Setup endpoint rejects passwords missing an uppercase character."""
|
||||||
@@ -123,11 +130,9 @@ class TestPostSetup:
|
|||||||
"/api/v1/setup",
|
"/api/v1/setup",
|
||||||
json={"master_password": "lowercase1!"},
|
json={"master_password": "lowercase1!"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 400
|
||||||
assert any(
|
body = response.json()
|
||||||
"uppercase" in error["msg"].lower()
|
assert body["code"] == "invalid_input"
|
||||||
for error in response.json()["detail"]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_rejects_missing_number_password(self, client: AsyncClient) -> None:
|
async def test_rejects_missing_number_password(self, client: AsyncClient) -> None:
|
||||||
"""Setup endpoint rejects passwords missing a numeric character."""
|
"""Setup endpoint rejects passwords missing a numeric character."""
|
||||||
@@ -135,11 +140,9 @@ class TestPostSetup:
|
|||||||
"/api/v1/setup",
|
"/api/v1/setup",
|
||||||
json={"master_password": "NoNumbers!"},
|
json={"master_password": "NoNumbers!"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 400
|
||||||
assert any(
|
body = response.json()
|
||||||
"number" in error["msg"].lower()
|
assert body["code"] == "invalid_input"
|
||||||
for error in response.json()["detail"]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_rejects_missing_special_character_password(
|
async def test_rejects_missing_special_character_password(
|
||||||
self, client: AsyncClient
|
self, client: AsyncClient
|
||||||
@@ -149,11 +152,9 @@ class TestPostSetup:
|
|||||||
"/api/v1/setup",
|
"/api/v1/setup",
|
||||||
json={"master_password": "NoSpecial1"},
|
json={"master_password": "NoSpecial1"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 400
|
||||||
assert any(
|
body = response.json()
|
||||||
"special character" in error["msg"].lower()
|
assert body["code"] == "invalid_input"
|
||||||
for error in response.json()["detail"]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def test_rejects_second_call(self, client: AsyncClient) -> None:
|
async def test_rejects_second_call(self, client: AsyncClient) -> None:
|
||||||
"""Setup endpoint returns 409 if setup has already been completed."""
|
"""Setup endpoint returns 409 if setup has already been completed."""
|
||||||
@@ -354,10 +355,20 @@ class TestLifespanDatabaseDirectoryCreation:
|
|||||||
nested_db = tmp_path / "deep" / "nested" / "bangui.db"
|
nested_db = tmp_path / "deep" / "nested" / "bangui.db"
|
||||||
assert not nested_db.parent.exists()
|
assert not nested_db.parent.exists()
|
||||||
|
|
||||||
|
# Settings requires the database parent to exist at construction time,
|
||||||
|
# so create the immediate parent. The intermediate directory
|
||||||
|
# (nested_db.parent.parent) is deliberately left non-existent to verify
|
||||||
|
# lifespan creates it.
|
||||||
|
nested_db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
config_dir = tmp_path / "fail2ban"
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
database_path=str(nested_db),
|
database_path=str(nested_db),
|
||||||
fail2ban_socket="/tmp/fake.sock",
|
fail2ban_socket="/tmp/fake.sock",
|
||||||
session_secret="test-lifespan-mkdir-secret",
|
fail2ban_config_dir=str(config_dir),
|
||||||
|
session_secret="test-lifespan-mkdir-secret-that-is-long-enough",
|
||||||
session_duration_minutes=60,
|
session_duration_minutes=60,
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
log_level="debug",
|
log_level="debug",
|
||||||
@@ -398,10 +409,14 @@ class TestLifespanDatabaseDirectoryCreation:
|
|||||||
db_path = tmp_path / "bangui.db"
|
db_path = tmp_path / "bangui.db"
|
||||||
# tmp_path already exists — this simulates a pre-existing volume.
|
# tmp_path already exists — this simulates a pre-existing volume.
|
||||||
|
|
||||||
|
config_dir = tmp_path / "fail2ban"
|
||||||
|
config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
database_path=str(db_path),
|
database_path=str(db_path),
|
||||||
fail2ban_socket="/tmp/fake.sock",
|
fail2ban_socket="/tmp/fake.sock",
|
||||||
session_secret="test-lifespan-exist-ok-secret",
|
fail2ban_config_dir=str(config_dir),
|
||||||
|
session_secret="test-lifespan-exist-ok-secret-that-is-long-enough",
|
||||||
session_duration_minutes=60,
|
session_duration_minutes=60,
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
log_level="debug",
|
log_level="debug",
|
||||||
@@ -436,10 +451,16 @@ class TestLifespanSetupCache:
|
|||||||
|
|
||||||
async def test_startup_caches_setup_completion(self, tmp_path: Path) -> None:
|
async def test_startup_caches_setup_completion(self, tmp_path: Path) -> None:
|
||||||
"""Lifespan should populate ``setup_complete_cached`` based on the DB."""
|
"""Lifespan should populate ``setup_complete_cached`` based on the DB."""
|
||||||
|
config_dir = tmp_path / "fail2ban"
|
||||||
|
(config_dir / "jail.d").mkdir(parents=True)
|
||||||
|
(config_dir / "filter.d").mkdir(parents=True)
|
||||||
|
(config_dir / "action.d").mkdir(parents=True)
|
||||||
|
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
database_path=str(tmp_path / "bangui.db"),
|
database_path=str(tmp_path / "bangui.db"),
|
||||||
fail2ban_socket="/tmp/fake.sock",
|
fail2ban_socket="/tmp/fake.sock",
|
||||||
session_secret="test-lifespan-setup-cache-secret",
|
fail2ban_config_dir=str(config_dir),
|
||||||
|
session_secret="test-lifespan-setup-cache-secret-that-is-long",
|
||||||
session_duration_minutes=60,
|
session_duration_minutes=60,
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
log_level="debug",
|
log_level="debug",
|
||||||
@@ -495,10 +516,16 @@ class TestSetupRedirectMiddlewareDbNone:
|
|||||||
Simulates the race window where a request arrives before the lifespan
|
Simulates the race window where a request arrives before the lifespan
|
||||||
has finished initialising the database connection.
|
has finished initialising the database connection.
|
||||||
"""
|
"""
|
||||||
|
config_dir = tmp_path / "fail2ban"
|
||||||
|
(config_dir / "jail.d").mkdir(parents=True)
|
||||||
|
(config_dir / "filter.d").mkdir(parents=True)
|
||||||
|
(config_dir / "action.d").mkdir(parents=True)
|
||||||
|
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
database_path=str(tmp_path / "bangui.db"),
|
database_path=str(tmp_path / "bangui.db"),
|
||||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||||
session_secret="test-db-none-secret",
|
fail2ban_config_dir=str(config_dir),
|
||||||
|
session_secret="test-db-none-secret-that-is-long-enough",
|
||||||
session_duration_minutes=60,
|
session_duration_minutes=60,
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
log_level="debug",
|
log_level="debug",
|
||||||
@@ -517,15 +544,22 @@ class TestSetupRedirectMiddlewareDbNone:
|
|||||||
|
|
||||||
async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None:
|
async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None:
|
||||||
"""Health endpoint is always reachable even when db is not initialised."""
|
"""Health endpoint is always reachable even when db is not initialised."""
|
||||||
|
config_dir = tmp_path / "fail2ban"
|
||||||
|
(config_dir / "jail.d").mkdir(parents=True)
|
||||||
|
(config_dir / "filter.d").mkdir(parents=True)
|
||||||
|
(config_dir / "action.d").mkdir(parents=True)
|
||||||
|
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
database_path=str(tmp_path / "bangui.db"),
|
database_path=str(tmp_path / "bangui.db"),
|
||||||
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
fail2ban_socket="/tmp/fake_fail2ban.sock",
|
||||||
session_secret="test-db-none-health-secret",
|
fail2ban_config_dir=str(config_dir),
|
||||||
|
session_secret="test-db-none-health-secret-that-is-long",
|
||||||
session_duration_minutes=60,
|
session_duration_minutes=60,
|
||||||
timezone="UTC",
|
timezone="UTC",
|
||||||
log_level="debug",
|
log_level="debug",
|
||||||
)
|
)
|
||||||
app = create_app(settings=settings)
|
app = create_app(settings=settings)
|
||||||
|
app.state.server_status = ServerStatus(online=True)
|
||||||
|
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(
|
async with AsyncClient(
|
||||||
|
|||||||
1812
backend/uv.lock
generated
Normal file
1812
backend/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'" />
|
||||||
<meta name="description" content="BanGUI — fail2ban management interface." />
|
<meta name="description" content="BanGUI — fail2ban management interface." />
|
||||||
<meta name="theme-color" content="#0F6CBD" />
|
<meta name="theme-color" content="#0F6CBD" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
|||||||
Reference in New Issue
Block a user