- Clic 🔨 : lookup AS via ip-api.com, propose 🔨 IP ou 🔨 AS (N préfixes) - Ban AS : récupère les CIDRs via RIPE Stat, cache 30 j dans as_cache/ - IPs déjà bannies (global-blacklist) masquées du tableau et du détail AJAX - ignoreip fail2ban : 82.65.88.34 protégée sur toutes les jails - Sudoers : permission status global-blacklist pour static-cdn Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3a6f363e1d
commit
5d2a4ab430
7 changed files with 251 additions and 44 deletions
|
|
@ -1,5 +1,18 @@
|
||||||
# Changelog — Alpinux Static
|
# Changelog — Alpinux Static
|
||||||
|
|
||||||
|
## [1.6.0] — 2026-05-06
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- Erreurs 404 : clic sur 🔨 affiche l'AS de l'IP (via ip-api.com) avec le nom, le pays et le nombre de préfixes IPv4
|
||||||
|
- Bannir l'IP seule **ou** bannir tout l'AS d'un coup (tous ses préfixes CIDR via RIPE Stat, cache 30 jours)
|
||||||
|
- Erreurs 404 : les IPs déjà bannies dans fail2ban (`global-blacklist`) sont masquées de la liste et du détail
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- fail2ban `ignoreip` : ton IP publique (`82.65.88.34`) protégée sur toutes les jails
|
||||||
|
- Sudoers `static-cdn` : ajout de la permission `fail2ban-client status global-blacklist`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.5.2] — 2026-05-06
|
## [1.5.2] — 2026-05-06
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1.5.2
|
1.6.0
|
||||||
|
|
|
||||||
148
app/app.py
148
app/app.py
|
|
@ -1,5 +1,6 @@
|
||||||
import gzip
|
import gzip
|
||||||
import io
|
import io
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -7,6 +8,7 @@ import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
@ -63,6 +65,12 @@ _404_CACHE: dict = {}
|
||||||
_404_CACHE_TS: float = 0
|
_404_CACHE_TS: float = 0
|
||||||
_404_CACHE_TTL = 300 # 5 min
|
_404_CACHE_TTL = 300 # 5 min
|
||||||
|
|
||||||
|
_AS_CACHE_DIR = Path(__file__).parent / "as_cache"
|
||||||
|
_AS_CACHE_TTL = 30 * 24 * 3600 # 30 jours
|
||||||
|
_BANNED_CACHE: tuple = (set(), [])
|
||||||
|
_BANNED_CACHE_TS: float = 0
|
||||||
|
_BANNED_CACHE_TTL = 60 # 1 min
|
||||||
|
|
||||||
|
|
||||||
_HIDDEN = frozenset({
|
_HIDDEN = frozenset({
|
||||||
".git", "scripts", "app",
|
".git", "scripts", "app",
|
||||||
|
|
@ -565,6 +573,88 @@ def _invalidate_404_cache():
|
||||||
_404_CACHE_TS = 0
|
_404_CACHE_TS = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Bannissement fail2ban — helpers ───────────────────────────────────
|
||||||
|
|
||||||
|
def _get_banned_ips() -> tuple:
|
||||||
|
"""Returns (set_of_ips, list_of_networks) from fail2ban global-blacklist."""
|
||||||
|
global _BANNED_CACHE, _BANNED_CACHE_TS
|
||||||
|
now = time.time()
|
||||||
|
if now - _BANNED_CACHE_TS < _BANNED_CACHE_TTL:
|
||||||
|
return _BANNED_CACHE
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["/usr/bin/sudo", "/usr/bin/fail2ban-client", "status", "global-blacklist"],
|
||||||
|
capture_output=True, text=True, timeout=5
|
||||||
|
)
|
||||||
|
raw: set = set()
|
||||||
|
for line in r.stdout.splitlines():
|
||||||
|
if "Banned IP list:" in line:
|
||||||
|
raw = set(line.split("Banned IP list:")[1].split())
|
||||||
|
break
|
||||||
|
ips, nets = set(), []
|
||||||
|
for entry in raw:
|
||||||
|
if "/" in entry:
|
||||||
|
try:
|
||||||
|
nets.append(ipaddress.ip_network(entry, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
ips.add(entry)
|
||||||
|
_BANNED_CACHE = (ips, nets)
|
||||||
|
except Exception:
|
||||||
|
_BANNED_CACHE = (set(), [])
|
||||||
|
_BANNED_CACHE_TS = now
|
||||||
|
return _BANNED_CACHE
|
||||||
|
|
||||||
|
def _ip_is_banned(ip: str) -> bool:
|
||||||
|
banned_ips, banned_nets = _get_banned_ips()
|
||||||
|
if ip in banned_ips:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
addr = ipaddress.ip_address(ip)
|
||||||
|
return any(addr in net for net in banned_nets)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _lookup_ip_asn(ip: str) -> dict:
|
||||||
|
"""Returns {asn, name, country} via ip-api.com."""
|
||||||
|
url = f"http://ip-api.com/json/{ip}?fields=as,org,countryCode"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "alpinux-static/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||||
|
d = json.loads(resp.read())
|
||||||
|
as_field = d.get("as", "") # "AS12345 Some Name"
|
||||||
|
asn, name = "", as_field
|
||||||
|
if as_field.startswith("AS"):
|
||||||
|
parts = as_field.split(" ", 1)
|
||||||
|
asn = parts[0][2:]
|
||||||
|
name = parts[1] if len(parts) > 1 else ""
|
||||||
|
return {"asn": asn, "name": name, "country": d.get("countryCode", "")}
|
||||||
|
except Exception as e:
|
||||||
|
return {"asn": "", "name": "", "country": "", "error": str(e)}
|
||||||
|
|
||||||
|
def _lookup_as_prefixes(asn: str) -> list:
|
||||||
|
"""Returns IPv4 CIDRs for an ASN via RIPE Stat. Cached 30 days."""
|
||||||
|
_AS_CACHE_DIR.mkdir(exist_ok=True)
|
||||||
|
cache_file = _AS_CACHE_DIR / f"AS{asn}.json"
|
||||||
|
now = time.time()
|
||||||
|
if cache_file.exists() and now - cache_file.stat().st_mtime < _AS_CACHE_TTL:
|
||||||
|
return json.loads(cache_file.read_text()).get("prefixes", [])
|
||||||
|
url = f"https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS{asn}"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "alpinux-static/1.0"})
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
data = json.loads(resp.read())
|
||||||
|
prefixes = [
|
||||||
|
p["prefix"] for p in data.get("data", {}).get("prefixes", [])
|
||||||
|
if ":" not in p.get("prefix", "")
|
||||||
|
]
|
||||||
|
cache_file.write_text(json.dumps({"asn": asn, "prefixes": prefixes}))
|
||||||
|
return prefixes
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ── Navigateur de fichiers ────────────────────────────────────────────
|
# ── Navigateur de fichiers ────────────────────────────────────────────
|
||||||
|
|
||||||
@app.route("/browse/")
|
@app.route("/browse/")
|
||||||
|
|
@ -820,18 +910,33 @@ def delete_file():
|
||||||
|
|
||||||
# ── Erreurs 404 ───────────────────────────────────────────────────────
|
# ── Erreurs 404 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _filter_banned_from_entry(info: dict) -> dict | None:
|
||||||
|
"""Remove banned IPs from an entry dict. Returns None if no IPs remain."""
|
||||||
|
ips_ok = {ip: hits for ip, hits in info["ips"].items() if not _ip_is_banned(ip)}
|
||||||
|
if not ips_ok:
|
||||||
|
return None
|
||||||
|
count = sum(len(h) for h in ips_ok.values())
|
||||||
|
last_seen = max(max(h["dt"] for h in hits) for hits in ips_ok.values())
|
||||||
|
return {"count": count, "last_seen": last_seen, "ips": ips_ok}
|
||||||
|
|
||||||
|
|
||||||
@app.route("/errors/")
|
@app.route("/errors/")
|
||||||
def errors_404():
|
def errors_404():
|
||||||
redir = _require_admin()
|
redir = _require_admin()
|
||||||
if redir:
|
if redir:
|
||||||
return redir
|
return redir
|
||||||
data = _parse_404s()
|
data = _parse_404s()
|
||||||
entries = sorted(data.items(), key=lambda x: x[1]["count"], reverse=True)
|
filtered = {}
|
||||||
|
for path, info in data.items():
|
||||||
|
entry = _filter_banned_from_entry(info)
|
||||||
|
if entry:
|
||||||
|
filtered[path] = entry
|
||||||
|
entries = sorted(filtered.items(), key=lambda x: x[1]["count"], reverse=True)
|
||||||
ignored = _load_ignored_ips()
|
ignored = _load_ignored_ips()
|
||||||
return render_template("errors_404.html",
|
return render_template("errors_404.html",
|
||||||
entries=entries,
|
entries=entries,
|
||||||
ignored_ips=sorted(ignored),
|
ignored_ips=sorted(ignored),
|
||||||
total=sum(v["count"] for v in data.values()),
|
total=sum(v["count"] for v in filtered.values()),
|
||||||
log_configured=bool(STATS_LOG_FILE),
|
log_configured=bool(STATS_LOG_FILE),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -843,9 +948,10 @@ def errors_detail():
|
||||||
return redir
|
return redir
|
||||||
path = request.args.get("path", "")
|
path = request.args.get("path", "")
|
||||||
data = _parse_404s()
|
data = _parse_404s()
|
||||||
entry = data.get(path)
|
raw_entry = data.get(path)
|
||||||
if not entry:
|
if not raw_entry:
|
||||||
return jsonify({"error": "introuvable"}), 404
|
return jsonify({"error": "introuvable"}), 404
|
||||||
|
entry = _filter_banned_from_entry(raw_entry) or {"count": 0, "last_seen": raw_entry["last_seen"], "ips": {}}
|
||||||
ignored = _load_ignored_ips()
|
ignored = _load_ignored_ips()
|
||||||
ip_list = []
|
ip_list = []
|
||||||
for ip, hits in sorted(entry["ips"].items(), key=lambda x: len(x[1]), reverse=True):
|
for ip, hits in sorted(entry["ips"].items(), key=lambda x: len(x[1]), reverse=True):
|
||||||
|
|
@ -869,6 +975,22 @@ def errors_detail():
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/errors/asinfo")
|
||||||
|
def errors_asinfo():
|
||||||
|
redir = _require_admin()
|
||||||
|
if redir:
|
||||||
|
return redir
|
||||||
|
ip = request.args.get("ip", "").strip()
|
||||||
|
if not re.match(r"^[\d\.]+$", ip):
|
||||||
|
return jsonify({"error": "IP invalide"}), 400
|
||||||
|
info = _lookup_ip_asn(ip)
|
||||||
|
if info.get("asn"):
|
||||||
|
info["prefix_count"] = len(_lookup_as_prefixes(info["asn"]))
|
||||||
|
else:
|
||||||
|
info["prefix_count"] = 0
|
||||||
|
return jsonify(info)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/errors/ignore", methods=["POST"])
|
@app.route("/errors/ignore", methods=["POST"])
|
||||||
def errors_ignore():
|
def errors_ignore():
|
||||||
redir = _require_admin()
|
redir = _require_admin()
|
||||||
|
|
@ -887,20 +1009,32 @@ def errors_ignore():
|
||||||
|
|
||||||
@app.route("/errors/ban", methods=["POST"])
|
@app.route("/errors/ban", methods=["POST"])
|
||||||
def errors_ban():
|
def errors_ban():
|
||||||
|
global _BANNED_CACHE_TS
|
||||||
redir = _require_admin()
|
redir = _require_admin()
|
||||||
if redir:
|
if redir:
|
||||||
return redir
|
return redir
|
||||||
ip = request.form.get("ip", "").strip()
|
ip = request.form.get("ip", "").strip()
|
||||||
jail = request.form.get("jail", "global-blacklist")
|
jail = request.form.get("jail", "global-blacklist")
|
||||||
|
ban_as = request.form.get("ban_as") == "1"
|
||||||
if not re.match(r"^[\d\.a-fA-F:]+$", ip):
|
if not re.match(r"^[\d\.a-fA-F:]+$", ip):
|
||||||
return jsonify({"error": "IP invalide"}), 400
|
return jsonify({"error": "IP invalide"}), 400
|
||||||
try:
|
try:
|
||||||
|
if ban_as:
|
||||||
|
info = _lookup_ip_asn(ip)
|
||||||
|
if not info.get("asn"):
|
||||||
|
return jsonify({"error": "ASN introuvable pour cette IP"}), 400
|
||||||
|
targets = _lookup_as_prefixes(info["asn"])
|
||||||
|
if not targets:
|
||||||
|
return jsonify({"error": "Aucun préfixe IPv4 trouvé pour cet AS"}), 400
|
||||||
|
else:
|
||||||
|
targets = [ip]
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["/usr/bin/sudo", "/usr/bin/fail2ban-client", "set", jail, "banip", ip],
|
["/usr/bin/sudo", "/usr/bin/fail2ban-client", "set", jail, "banip"] + targets,
|
||||||
capture_output=True, text=True, timeout=10
|
capture_output=True, text=True, timeout=60
|
||||||
)
|
)
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
return jsonify({"ok": True, "ip": ip, "jail": jail})
|
_BANNED_CACHE_TS = 0 # invalide le cache pour ce worker
|
||||||
|
return jsonify({"ok": True, "ip": ip, "jail": jail, "count": len(targets)})
|
||||||
return jsonify({"error": r.stderr.strip() or "Erreur fail2ban"}), 500
|
return jsonify({"error": r.stderr.strip() or "Erreur fail2ban"}), 500
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,15 @@ footer { background: var(--blue-dark); color: rgba(255,255,255,.6); margin-top:
|
||||||
.err-search { flex: 1; max-width: 380px; padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 8px; font-size: .9rem; background: var(--bg); color: var(--text); }
|
.err-search { flex: 1; max-width: 380px; padding: .4rem .75rem; border: 1px solid var(--border); border-radius: 8px; font-size: .9rem; background: var(--bg); color: var(--text); }
|
||||||
.err-search:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-light); }
|
.err-search:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 2px var(--blue-light); }
|
||||||
.err-search-count { font-size: .8rem; color: var(--muted); }
|
.err-search-count { font-size: .8rem; color: var(--muted); }
|
||||||
|
.ban-panel { display: flex; flex-direction: column; gap: .35rem; min-width: 200px; }
|
||||||
|
.ban-panel-as { font-size: .75rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 240px; }
|
||||||
|
.ban-panel-btns { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||||
|
.btn-do-ban { font-size: .75rem; padding: .2rem .5rem; background: var(--red-light,#fee2e2); color: #b91c1c; border: 1px solid #fca5a5; border-radius: 5px; cursor: pointer; white-space: nowrap; }
|
||||||
|
.btn-do-ban--as { background: #fff7ed; color: #c2410c; border-color: #fdba74; }
|
||||||
|
.btn-do-ban:hover:not(:disabled) { filter: brightness(.92); }
|
||||||
|
.btn-ban-cancel { font-size: .75rem; padding: .2rem .45rem; background: none; border: 1px solid var(--border); border-radius: 5px; cursor: pointer; color: var(--muted); }
|
||||||
|
.ban-ok { font-size: .82rem; color: #16a34a; }
|
||||||
|
.ban-err { font-size: .82rem; color: #dc2626; }
|
||||||
|
|
||||||
.err-table .err-path code { font-size: .82rem; color: var(--text); word-break: break-all; }
|
.err-table .err-path code { font-size: .82rem; color: var(--text); word-break: break-all; }
|
||||||
.col-err-status { width: 1.5rem; text-align: center; }
|
.col-err-status { width: 1.5rem; text-align: center; }
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,10 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const DETAIL_URL = {{ url_for('errors_detail') | tojson }};
|
const DETAIL_URL = {{ url_for('errors_detail') | tojson }};
|
||||||
const IGNORE_URL = {{ url_for('errors_ignore') | tojson }};
|
const IGNORE_URL = {{ url_for('errors_ignore') | tojson }};
|
||||||
const BAN_URL = {{ url_for('errors_ban') | tojson }};
|
const BAN_URL = {{ url_for('errors_ban') | tojson }};
|
||||||
|
const ASINFO_URL = {{ url_for('errors_asinfo') | tojson }};
|
||||||
|
|
||||||
/* ── Search / filter ── */
|
/* ── Search / filter ── */
|
||||||
const searchInput = document.getElementById('err-search');
|
const searchInput = document.getElementById('err-search');
|
||||||
|
|
@ -204,26 +205,56 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Ban buttons */
|
/* Ban buttons — 2 étapes : affiche l'AS puis propose IP seule ou AS entier */
|
||||||
container.querySelectorAll('.btn-ban-ip').forEach(b => {
|
container.querySelectorAll('.btn-ban-ip').forEach(b => {
|
||||||
b.addEventListener('click', async () => {
|
b.addEventListener('click', async () => {
|
||||||
if (!confirm(`Bannir ${b.dataset.ip} dans fail2ban ?`)) return;
|
const ip = b.dataset.ip;
|
||||||
b.disabled = true;
|
const actionsDiv = b.closest('.row-actions');
|
||||||
b.textContent = '⏳';
|
b.disabled = true; b.textContent = '⏳';
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('ip', b.dataset.ip);
|
let asInfo = {};
|
||||||
const r = await fetch(BAN_URL, { method: 'POST', body: fd });
|
try {
|
||||||
const j = await r.json();
|
asInfo = await fetch(ASINFO_URL + '?ip=' + encodeURIComponent(ip)).then(r => r.json());
|
||||||
if (j.ok) {
|
} catch (_) {}
|
||||||
b.textContent = '✓';
|
|
||||||
b.style.color = '#16a34a';
|
const asLabel = asInfo.asn
|
||||||
} else {
|
? `AS${asInfo.asn} · ${escHtml(asInfo.name)}${asInfo.country ? ' [' + asInfo.country + ']' : ''}`
|
||||||
b.textContent = '✗';
|
: '<span style="color:var(--muted)">AS inconnu</span>';
|
||||||
b.title = j.error || 'Erreur';
|
const n = asInfo.prefix_count || 0;
|
||||||
b.style.color = '#dc2626';
|
|
||||||
b.disabled = false;
|
actionsDiv.innerHTML =
|
||||||
alert('Erreur : ' + (j.error || 'voir console'));
|
`<div class="ban-panel">
|
||||||
}
|
<span class="ban-panel-as">${asLabel}</span>
|
||||||
|
<div class="ban-panel-btns">
|
||||||
|
<button class="btn-do-ban" data-ban-as="0" data-ip="${escHtml(ip)}" title="Bannir cette IP uniquement">🔨 IP</button>`
|
||||||
|
+ (n ? `<button class="btn-do-ban btn-do-ban--as" data-ban-as="1" data-ip="${escHtml(ip)}" data-count="${n}" title="Bannir les ${n} préfixes de cet AS">🔨 AS (${n})</button>` : '')
|
||||||
|
+ `<button class="btn-ban-cancel" title="Annuler">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
actionsDiv.querySelector('.btn-ban-cancel').addEventListener('click', () => {
|
||||||
|
loadDetail(d.path, container);
|
||||||
|
});
|
||||||
|
|
||||||
|
actionsDiv.querySelectorAll('.btn-do-ban').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const banAs = btn.dataset.banAs === '1';
|
||||||
|
if (banAs && !confirm(`Bannir les ${btn.dataset.count} préfixes de cet AS ?\nToutes les IPs de cet AS seront bloquées.`)) return;
|
||||||
|
actionsDiv.querySelectorAll('button').forEach(x => x.disabled = true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('ip', btn.dataset.ip);
|
||||||
|
if (banAs) fd.append('ban_as', '1');
|
||||||
|
const r = await fetch(BAN_URL, { method: 'POST', body: fd });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) {
|
||||||
|
const label = banAs ? `✓ AS banni (${j.count} préfixes)` : '✓ Banni';
|
||||||
|
actionsDiv.innerHTML = `<span class="ban-ok">${label}</span>`;
|
||||||
|
setTimeout(() => loadDetail(d.path, container), 1500);
|
||||||
|
} else {
|
||||||
|
actionsDiv.innerHTML = `<span class="ban-err" title="${escHtml(j.error||'')}">✗ ${escHtml(j.error||'Erreur')}</span>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,14 @@ fi
|
||||||
source "$ENV_FILE"
|
source "$ENV_FILE"
|
||||||
|
|
||||||
LOCAL_DIR="${LOCAL_ASSETS_DIR:-/tmp/alpinux-static-assets}"
|
LOCAL_DIR="${LOCAL_ASSETS_DIR:-/tmp/alpinux-static-assets}"
|
||||||
REMOTE_USER="${STATIC_USER:?Variable STATIC_USER manquante dans .env}"
|
|
||||||
REMOTE_HOST="${STATIC_HOST:?Variable STATIC_HOST manquante dans .env}"
|
REMOTE_HOST="${STATIC_HOST:?Variable STATIC_HOST manquante dans .env}"
|
||||||
REMOTE_PATH="${STATIC_PATH:?Variable STATIC_PATH manquante dans .env}"
|
REMOTE_PATH="${STATIC_PATH:?Variable STATIC_PATH manquante dans .env}"
|
||||||
REMOTE="${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
|
# STATIC_USER optionnel : si absent, l'alias ~/.ssh/config est utilisé directement
|
||||||
|
if [ -n "${STATIC_USER:-}" ]; then
|
||||||
|
REMOTE="${STATIC_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
|
||||||
|
else
|
||||||
|
REMOTE="${REMOTE_HOST}:${REMOTE_PATH}/"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Arguments ───────────────────────────────────────────────────────
|
# ── Arguments ───────────────────────────────────────────────────────
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
|
|
@ -51,9 +55,9 @@ echo -e " Source : ${CYAN}$REMOTE${RESET}"
|
||||||
echo -e " Cible : ${CYAN}$LOCAL_DIR/${RESET}"
|
echo -e " Cible : ${CYAN}$LOCAL_DIR/${RESET}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
EXCLUDES=(--exclude='.git/' --exclude='scripts/' --exclude='README.md'
|
EXCLUDES=(--exclude='.git/' --exclude='scripts/' --exclude='app/'
|
||||||
--exclude='.env' --exclude='.env.example' --exclude='.gitignore'
|
--exclude='README.md' --exclude='.env' --exclude='.env.example'
|
||||||
--exclude='wiki/')
|
--exclude='.gitignore')
|
||||||
|
|
||||||
DIFF=$(rsync -rlcz --dry-run --itemize-changes \
|
DIFF=$(rsync -rlcz --dry-run --itemize-changes \
|
||||||
--rsync-path="sudo rsync" \
|
--rsync-path="sudo rsync" \
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,14 @@ fi
|
||||||
source "$ENV_FILE"
|
source "$ENV_FILE"
|
||||||
|
|
||||||
LOCAL_DIR="${LOCAL_ASSETS_DIR:-/tmp/alpinux-static-assets}"
|
LOCAL_DIR="${LOCAL_ASSETS_DIR:-/tmp/alpinux-static-assets}"
|
||||||
REMOTE_USER="${STATIC_USER:?Variable STATIC_USER manquante dans .env}"
|
|
||||||
REMOTE_HOST="${STATIC_HOST:?Variable STATIC_HOST manquante dans .env}"
|
REMOTE_HOST="${STATIC_HOST:?Variable STATIC_HOST manquante dans .env}"
|
||||||
REMOTE_PATH="${STATIC_PATH:?Variable STATIC_PATH manquante dans .env}"
|
REMOTE_PATH="${STATIC_PATH:?Variable STATIC_PATH manquante dans .env}"
|
||||||
REMOTE="${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
|
# STATIC_USER optionnel : si absent, l'alias ~/.ssh/config est utilisé directement
|
||||||
|
if [ -n "${STATIC_USER:-}" ]; then
|
||||||
|
REMOTE="${STATIC_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
|
||||||
|
else
|
||||||
|
REMOTE="${REMOTE_HOST}:${REMOTE_PATH}/"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Arguments ───────────────────────────────────────────────────────
|
# ── Arguments ───────────────────────────────────────────────────────
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
|
|
@ -55,11 +59,11 @@ echo -e " Source : ${CYAN}$LOCAL_DIR/${RESET}"
|
||||||
echo -e " Cible : ${CYAN}$REMOTE${RESET}"
|
echo -e " Cible : ${CYAN}$REMOTE${RESET}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
EXCLUDES=(--exclude='.git/' --exclude='scripts/' --exclude='README.md'
|
EXCLUDES=(--exclude='.git/' --exclude='scripts/' --exclude='app/'
|
||||||
--exclude='.env' --exclude='.env.example' --exclude='.gitignore'
|
--exclude='README.md' --exclude='.env' --exclude='.env.example'
|
||||||
--exclude='wiki/')
|
--exclude='.gitignore')
|
||||||
|
|
||||||
DIFF=$(rsync -rlcz --dry-run --itemize-changes \
|
DIFF=$(rsync -rlcz --dry-run --itemize-changes --delete \
|
||||||
--rsync-path="sudo rsync" \
|
--rsync-path="sudo rsync" \
|
||||||
"${EXCLUDES[@]}" \
|
"${EXCLUDES[@]}" \
|
||||||
"$LOCAL_DIR/" "$REMOTE" 2>&1)
|
"$LOCAL_DIR/" "$REMOTE" 2>&1)
|
||||||
|
|
@ -73,10 +77,10 @@ while IFS= read -r line; do
|
||||||
if [[ "$item" == *"deleting"* ]]; then
|
if [[ "$item" == *"deleting"* ]]; then
|
||||||
echo -e " ${RED}supprimé ${RESET} $file"
|
echo -e " ${RED}supprimé ${RESET} $file"
|
||||||
DELETED=$(( DELETED + 1 ))
|
DELETED=$(( DELETED + 1 ))
|
||||||
elif [[ "$item" =~ ^\>f\+{6,} ]]; then
|
elif [[ "$item" =~ ^\<f\+{6,} ]]; then
|
||||||
echo -e " ${GREEN}nouveau ${RESET} $file"
|
echo -e " ${GREEN}nouveau ${RESET} $file"
|
||||||
NEW=$(( NEW + 1 ))
|
NEW=$(( NEW + 1 ))
|
||||||
elif [[ "$item" =~ ^\>f ]]; then
|
elif [[ "$item" =~ ^\<f ]]; then
|
||||||
echo -e " ${YELLOW}modifié ${RESET} $file"
|
echo -e " ${YELLOW}modifié ${RESET} $file"
|
||||||
CHANGED=$(( CHANGED + 1 ))
|
CHANGED=$(( CHANGED + 1 ))
|
||||||
fi
|
fi
|
||||||
|
|
@ -101,11 +105,23 @@ if ! $AUTO_YES; then
|
||||||
[[ "$confirm" =~ ^[oOyY]$ ]] || { echo "Annulé."; exit 0; }
|
[[ "$confirm" =~ ^[oOyY]$ ]] || { echo "Annulé."; exit 0; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
DELETE_REMOTE=false
|
||||||
|
if [ "$DELETED" -gt 0 ]; then
|
||||||
|
echo -e "${RED}${BOLD}Attention : $DELETED fichier(s) absent(s) en local seront supprimés du serveur.${RESET}"
|
||||||
|
read -rp "Confirmer la suppression sur le serveur ? [o/N] " confirm_del
|
||||||
|
if [[ "$confirm_del" =~ ^[oOyY]$ ]]; then
|
||||||
|
DELETE_REMOTE=true
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Suppressions ignorées — seuls les fichiers nouveaux/modifiés seront poussés.${RESET}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Synchronisation ─────────────────────────────────────────────────
|
# ── Synchronisation ─────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BOLD}Synchronisation en cours…${RESET}"
|
echo -e "${BOLD}Synchronisation en cours…${RESET}"
|
||||||
rsync -rlcz --human-readable --progress \
|
SYNC_FLAGS=(-rlcz --human-readable --progress --rsync-path="sudo rsync")
|
||||||
--rsync-path="sudo rsync" \
|
$DELETE_REMOTE && SYNC_FLAGS+=(--delete)
|
||||||
|
rsync "${SYNC_FLAGS[@]}" \
|
||||||
"${EXCLUDES[@]}" \
|
"${EXCLUDES[@]}" \
|
||||||
"$LOCAL_DIR/" "$REMOTE"
|
"$LOCAL_DIR/" "$REMOTE"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue