Compare commits

..

No commits in common. "bd4f4c62ae888d4aed4c35233457d9f5168047f7" and "a6d7bc2c8a2d724b57f834348b80377943ee8270" have entirely different histories.

9 changed files with 31 additions and 226 deletions

View file

@ -13,4 +13,4 @@ STATIC_HOST=alpinux.org
STATIC_PATH=/var/www/clients/client1/web17/web STATIC_PATH=/var/www/clients/client1/web17/web
# Répertoire local source (racine du repo alpinux-static) # Répertoire local source (racine du repo alpinux-static)
LOCAL_ASSETS_DIR=<chemin absolu vers alpinux.static> LOCAL_ASSETS_DIR=<chemin absolu vers org.alpinux.owni/static>

1
.gitignore vendored
View file

@ -1,2 +1 @@
.env .env
__pycache__/

View file

@ -3,8 +3,6 @@
CDN pour les fichiers binaires et assets statiques de l'association : logos, favicons, images Open Graph. CDN pour les fichiers binaires et assets statiques de l'association : logos, favicons, images Open Graph.
Tableau de bord d'administration accessible sur `https://static.alpinux.org` (authentification AlpID). Tableau de bord d'administration accessible sur `https://static.alpinux.org` (authentification AlpID).
Dépôt : [alpinux-static](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-static) — localement `~/Projects/alpinux.static/` (un domaine, un dépôt).
--- ---
## Flux de publication ## Flux de publication
@ -21,7 +19,7 @@ Git sert à **versionner et sauvegarder** le code sur Gitea. Il n'est pas dans l
### Modifier les assets CDN (logo, wiki, stats…) ### Modifier les assets CDN (logo, wiki, stats…)
```bash ```bash
# 1. Modifier les fichiers à la racine du dépôt (logo/, wiki/, error/…) # 1. Modifier les fichiers dans static/
# 2. Versionner (optionnel mais recommandé) # 2. Versionner (optionnel mais recommandé)
git add <fichiers> git add <fichiers>
git commit -m "..." git commit -m "..."
@ -38,7 +36,7 @@ git push # sauvegarde sur gitea.alpinux.org
### Modifier l'app Flask (`app/`) ### Modifier l'app Flask (`app/`)
```bash ```bash
# 1. Modifier les fichiers dans app/ # 1. Modifier les fichiers dans static/app/
# 2. Versionner # 2. Versionner
git add app/ git add app/
git commit -m "..." git commit -m "..."
@ -64,7 +62,7 @@ cp .env.example .env
|----------|-------------|-------------| |----------|-------------|-------------|
| `STATIC_HOST` | oui | Alias SSH ou nom d'hôte (`alpinux.org`) | | `STATIC_HOST` | oui | Alias SSH ou nom d'hôte (`alpinux.org`) |
| `STATIC_PATH` | oui | Chemin absolu du web root sur le serveur | | `STATIC_PATH` | oui | Chemin absolu du web root sur le serveur |
| `LOCAL_ASSETS_DIR` | oui | Chemin absolu du dépôt local (`~/Projects/alpinux.static`) | | `LOCAL_ASSETS_DIR` | oui | Chemin absolu du dépôt local (`static/`) |
| `STATIC_USER` | non | Login SSH — laisser vide si `~/.ssh/config` définit l'utilisateur | | `STATIC_USER` | non | Login SSH — laisser vide si `~/.ssh/config` définit l'utilisateur |
Si l'hôte est déclaré dans `~/.ssh/config` (recommandé), laisser `STATIC_USER` vide. Si l'hôte est déclaré dans `~/.ssh/config` (recommandé), laisser `STATIC_USER` vide.
@ -102,18 +100,10 @@ Authentification SSO via AlpID (Keycloak).
| Route | Description | | Route | Description |
|-------|-------------| |-------|-------------|
| `/` | Tableau de bord — statistiques CDN par dossier | | `/` | Tableau de bord — statistiques CDN par dossier |
| `/browse/` | Navigateur de fichiers — aperçu, suppression, **upload**, **création de dossier** | | `/browse/` | Navigateur de fichiers — aperçu, suppression, **upload** |
| `/search` | Recherche par nom / contenu / date | | `/search` | Recherche par nom / contenu / date |
| `/stats/` | Rapport de trafic GoAccess | | `/stats/` | Rapport de trafic GoAccess |
### Création de dossier
Le bouton « 📁 Nouveau dossier », à droite du fil d'Ariane de `/browse/`, crée un
sous-dossier dans le répertoire affiché (Entrée pour valider, Échap pour annuler).
Le nom est nettoyé par `secure_filename` — accents et espaces sont normalisés.
Sont refusés : les noms réservés ou cachés (`app`, `scripts`, `.git`, …), les
traversées de chemin et les dossiers déjà existants.
### Upload de fichiers ### Upload de fichiers
Dans le navigateur (`/browse/`), une zone de dépôt est affichée en bas de chaque dossier. Dans le navigateur (`/browse/`), une zone de dépôt est affichée en bas de chaque dossier.
@ -204,56 +194,6 @@ sudo systemctl enable --now static-cdn
--- ---
## Bannissement fail2ban (`/errors/`)
### Architecture
La page `/errors/` permet de bannir des IPs ou des AS entiers. Les bans sont **permanents** (`bantime = -1`).
| Composant | Valeur |
|-----------|--------|
| Jail fail2ban | `global-blacklist` |
| Backend | `iptables-ipset-net-allports` (ipset `hash:net`, O(1)) |
| Ipset kernel | `f2b-global-blacklist` |
| Persistance bans | Base SQLite fail2ban (`bantime = -1` → jamais expirés) |
| Lecture bans (app) | `sudo /usr/sbin/ipset list f2b-global-blacklist` |
| Écriture bans (app) | `sudo /usr/bin/fail2ban-client set global-blacklist banip/unbanip` |
> **Pourquoi ipset ?** Avec `iptables-allports`, chaque ban ajoutait une règle individuelle. À partir de ~5 000 entrées, les bans en rafale (ban AS = plusieurs /24) dépassaient le timeout de 60 s. `hash:net` effectue lookup et insertion en O(1) quel que soit le nombre d'entrées.
### Sudoers
Fichier : `/etc/sudoers.d/static-cdn-fail2ban` (user `static-cdn`)
```
static-cdn ALL=(ALL) NOPASSWD: /usr/bin/fail2ban-client set global-blacklist banip *
static-cdn ALL=(ALL) NOPASSWD: /usr/bin/fail2ban-client set global-blacklist unbanip *
static-cdn ALL=(ALL) NOPASSWD: /usr/bin/fail2ban-client status global-blacklist
static-cdn ALL=(ALL) NOPASSWD: /usr/sbin/ipset list f2b-global-blacklist
```
### Configuration fail2ban
`/etc/fail2ban/jail.local` :
```ini
[global-blacklist]
enabled = true
filter = global-blacklist
bantime = -1
action = iptables-ipset-net-allports[name=global-blacklist]
logpath = /etc/fail2ban/badips.list
```
Action custom : `/etc/fail2ban/action.d/iptables-ipset-net-allports.conf`
(hérite de `iptables.conf`, crée un ipset `hash:net family inet`)
### Restauration après redémarrage fail2ban
Avec `bantime = -1`, fail2ban restaure **tous** les bans de sa DB SQLite au démarrage — il n'y a pas d'expiration. Le redémarrage du service prend quelques secondes même avec 20 000+ entrées (ipset = insertion kernel rapide).
---
## Voir aussi ## Voir aussi
Vue d'ensemble des projets, procédures de déploiement globales et accès : Vue d'ensemble des projets, procédures de déploiement globales et accès :

View file

@ -14,7 +14,7 @@ ADMIN_EMAILS=cedric.alpinux@acemail.fr
# ── Répertoire racine du CDN à parcourir ────────────────────────────── # ── Répertoire racine du CDN à parcourir ──────────────────────────────
# Production : /var/www/clients/client1/web17/web # Production : /var/www/clients/client1/web17/web
# Dev local : chemin absolu vers alpinux.static # Dev local : chemin absolu vers org.alpinux.owni/static
ASSETS_ROOT=<chemin absolu> ASSETS_ROOT=<chemin absolu>
# ── Statistiques GoAccess ───────────────────────────────────────────── # ── Statistiques GoAccess ─────────────────────────────────────────────

View file

@ -1,13 +1,5 @@
# Changelog — Alpinux Static # Changelog — Alpinux Static
## [2.1.0] — 2026-09-05
### Ajouté
- **Parcourir** : bouton « 📁 Nouveau dossier » à droite du fil d'Ariane — saisie en ligne (Entrée pour valider, Échap pour annuler), le dossier est créé dans le répertoire affiché
- Nouveau endpoint `POST /mkdir` : réservé aux admins, nom nettoyé par `secure_filename`, refus des noms réservés/cachés, des traversées de chemin et des doublons (409)
---
## [2.0.0] — 2026-05-06 ## [2.0.0] — 2026-05-06
### Ajouté ### Ajouté

View file

@ -1 +1 @@
2.1.0 2.0.0

View file

@ -589,30 +589,28 @@ def _invalidate_404_cache():
# ── Bannissement fail2ban — helpers ─────────────────────────────────── # ── Bannissement fail2ban — helpers ───────────────────────────────────
def _get_banned_ips() -> tuple: def _get_banned_ips() -> tuple:
"""Returns (set_of_ips, list_of_networks) from ipset f2b-global-blacklist.""" """Returns (set_of_ips, list_of_networks) from fail2ban global-blacklist."""
global _BANNED_CACHE, _BANNED_CACHE_TS global _BANNED_CACHE, _BANNED_CACHE_TS
now = time.time() now = time.time()
if now - _BANNED_CACHE_TS < _BANNED_CACHE_TTL: if now - _BANNED_CACHE_TS < _BANNED_CACHE_TTL:
return _BANNED_CACHE return _BANNED_CACHE
try: try:
r = subprocess.run( r = subprocess.run(
["/usr/bin/sudo", "/usr/sbin/ipset", "list", "f2b-global-blacklist"], ["/usr/bin/sudo", "/usr/bin/fail2ban-client", "status", "global-blacklist"],
capture_output=True, text=True, timeout=15 capture_output=True, text=True, timeout=30
) )
if r.returncode != 0: if r.returncode != 0:
app.logger.error("ipset list rc=%d stderr=%s", r.returncode, r.stderr[:500]) app.logger.error("fail2ban status rc=%d stderr=%s", r.returncode, r.stderr[:500])
_BANNED_CACHE = (set(), []) _BANNED_CACHE = (set(), [])
_BANNED_CACHE_TS = now _BANNED_CACHE_TS = now
return _BANNED_CACHE return _BANNED_CACHE
ips, nets = set(), [] raw: set = set()
in_members = False
for line in r.stdout.splitlines(): for line in r.stdout.splitlines():
if line.startswith("Members:"): if "Banned IP list:" in line:
in_members = True raw = set(line.split("Banned IP list:")[1].split())
continue break
if not in_members or not line.strip(): ips, nets = set(), []
continue for entry in raw:
entry = line.split()[0] # ignore optional timeout field
if "/" in entry: if "/" in entry:
try: try:
nets.append(ipaddress.ip_network(entry, strict=False)) nets.append(ipaddress.ip_network(entry, strict=False))
@ -620,13 +618,13 @@ def _get_banned_ips() -> tuple:
pass pass
else: else:
ips.add(entry) ips.add(entry)
app.logger.info("ipset: %d IPs, %d nets loaded", len(ips), len(nets)) app.logger.info("fail2ban: %d IPs, %d nets loaded", len(ips), len(nets))
_BANNED_CACHE = (ips, nets) _BANNED_CACHE = (ips, nets)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
app.logger.error("ipset list timed out after 15s") app.logger.error("fail2ban status timed out after 30s")
_BANNED_CACHE = (set(), []) _BANNED_CACHE = (set(), [])
except Exception as e: except Exception as e:
app.logger.error("ipset list error: %s", e) app.logger.error("fail2ban status error: %s", e)
_BANNED_CACHE = (set(), []) _BANNED_CACHE = (set(), [])
_BANNED_CACHE_TS = now _BANNED_CACHE_TS = now
return _BANNED_CACHE return _BANNED_CACHE
@ -1611,56 +1609,6 @@ def rename_file():
}) })
# ── Création de dossier ──────────────────────────────────────────────
@app.route("/mkdir", methods=["POST"])
def make_dir():
redir = _require_admin()
if redir:
return redir
subpath = request.form.get("path", "").strip()
name = request.form.get("name", "").strip()
if not name:
return jsonify({"error": "Nom manquant"}), 400
if "/" in name or "\\" in name or name in (".", ".."):
return jsonify({"error": "Nom invalide"}), 400
name = secure_filename(name)
if not name or name.startswith(".") or name in _HIDDEN:
return jsonify({"error": "Nom invalide après nettoyage"}), 400
if subpath:
parts = Path(subpath).parts
if parts and (parts[0] in _HIDDEN or parts[0].startswith(".")):
return jsonify({"error": "Accès refusé"}), 403
parent = _safe_path(subpath) if subpath else ASSETS_ROOT
if not parent.is_dir():
return jsonify({"error": "Dossier parent introuvable"}), 404
dest = parent / name
if not dest.is_relative_to(ASSETS_ROOT):
return jsonify({"error": "Destination invalide"}), 400
if dest.exists():
return jsonify({"error": f"« {name} » existe déjà"}), 409
try:
dest.mkdir()
except OSError as exc:
return jsonify({"error": f"Création impossible : {exc.strerror}"}), 500
new_path = str(dest.relative_to(ASSETS_ROOT))
return jsonify({
"name": name,
"path": new_path,
"browse_url": url_for("browse", subpath=new_path),
})
# ── Vérification des conflits avant upload ─────────────────────────── # ── Vérification des conflits avant upload ───────────────────────────
@app.route("/check-upload", methods=["POST"]) @app.route("/check-upload", methods=["POST"])

View file

@ -91,15 +91,6 @@ html[data-cw="1600"] main { max-width: 1600px; }
.breadcrumb .sep { color: var(--muted); } .breadcrumb .sep { color: var(--muted); }
.breadcrumb .current { color: var(--text); font-weight: 600; } .breadcrumb .current { color: var(--text); font-weight: 600; }
/* ── Barre du dossier courant + nouveau dossier ───────────────── */
.browse-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .8rem; }
.browse-head .breadcrumb { margin-bottom: 0; }
.mkdir { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.mkdir-form { display: none; align-items: center; gap: .25rem; }
.mkdir-form:not([hidden]) { display: flex; }
.mkdir-form .rename-input { min-width: 180px; }
.mkdir-error { font-size: .8rem; color: #b91c1c; }
/* ── Tableau de fichiers ──────────────────────────────────────── */ /* ── Tableau de fichiers ──────────────────────────────────────── */
.file-table { width: 100%; border-collapse: collapse; font-size: .9rem; } .file-table { width: 100%; border-collapse: collapse; font-size: .9rem; }
.file-table th { text-align: left; padding: .55rem .8rem; background: var(--blue-light); color: var(--blue-dark); font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; } .file-table th { text-align: left; padding: .55rem .8rem; background: var(--blue-light); color: var(--blue-dark); font-weight: 600; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; }

View file

@ -5,30 +5,17 @@
<section class="card"> <section class="card">
<div class="browse-head"> <nav class="breadcrumb">
<nav class="breadcrumb"> <a href="{{ url_for('browse') }}">CDN</a>
<a href="{{ url_for('browse') }}">CDN</a> {% for crumb in breadcrumb %}
{% for crumb in breadcrumb %} <span class="sep">/</span>
<span class="sep">/</span> {% if loop.last %}
{% if loop.last %} <span class="current">{{ crumb.name }}</span>
<span class="current">{{ crumb.name }}</span> {% else %}
{% else %} <a href="{{ url_for('browse', subpath=crumb.path) }}">{{ crumb.name }}</a>
<a href="{{ url_for('browse', subpath=crumb.path) }}">{{ crumb.name }}</a> {% endif %}
{% endif %} {% endfor %}
{% endfor %} </nav>
</nav>
<div class="mkdir">
<button type="button" class="btn-sm" id="mkdir-open">📁 Nouveau dossier</button>
<div class="mkdir-form" id="mkdir-form" hidden>
<input type="text" class="rename-input" id="mkdir-input"
placeholder="Nom du dossier" autocomplete="off" maxlength="100">
<button type="button" class="btn-rename-ok" id="mkdir-ok" title="Créer">✓</button>
<button type="button" class="btn-rename-cancel" id="mkdir-cancel" title="Annuler">✕</button>
</div>
<span class="mkdir-error" id="mkdir-error"></span>
</div>
</div>
{% if entries or subpath %} {% if entries or subpath %}
{% set mode = 'browse' %} {% set mode = 'browse' %}
@ -127,58 +114,6 @@
</script> </script>
</section> </section>
<script>
(function () {
const open = document.getElementById('mkdir-open');
const form = document.getElementById('mkdir-form');
const inp = document.getElementById('mkdir-input');
const err = document.getElementById('mkdir-error');
const PATH = {{ subpath | tojson }};
function close() {
form.hidden = true;
open.hidden = false;
err.textContent = '';
inp.value = '';
}
open.addEventListener('click', () => {
open.hidden = true;
form.hidden = false;
inp.focus();
});
document.getElementById('mkdir-cancel').addEventListener('click', close);
async function create() {
const name = inp.value.trim();
if (!name) { close(); return; }
err.textContent = '';
const fd = new FormData();
fd.append('path', PATH);
fd.append('name', name);
try {
const resp = await fetch({{ url_for('make_dir') | tojson }}, { method: 'POST', body: fd });
const data = await resp.json();
if (data.error) {
err.textContent = data.error;
inp.focus(); inp.select();
return;
}
location.reload();
} catch (_) {
err.textContent = 'Création impossible';
}
}
document.getElementById('mkdir-ok').addEventListener('click', create);
inp.addEventListener('keydown', ev => {
if (ev.key === 'Enter') { ev.preventDefault(); create(); }
if (ev.key === 'Escape') close();
});
})();
</script>
<script> <script>
(function () { (function () {
function escHtml(s) { function escHtml(s) {