- 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
|
||||
|
||||
## [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
|
||||
|
||||
### Ajouté
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.5.2
|
||||
1.6.0
|
||||
|
|
|
|||
148
app/app.py
148
app/app.py
|
|
@ -1,5 +1,6 @@
|
|||
import gzip
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -7,6 +8,7 @@ import shutil
|
|||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
|
@ -63,6 +65,12 @@ _404_CACHE: dict = {}
|
|||
_404_CACHE_TS: float = 0
|
||||
_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({
|
||||
".git", "scripts", "app",
|
||||
|
|
@ -565,6 +573,88 @@ def _invalidate_404_cache():
|
|||
_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 ────────────────────────────────────────────
|
||||
|
||||
@app.route("/browse/")
|
||||
|
|
@ -820,18 +910,33 @@ def delete_file():
|
|||
|
||||
# ── 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/")
|
||||
def errors_404():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
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()
|
||||
return render_template("errors_404.html",
|
||||
entries=entries,
|
||||
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),
|
||||
)
|
||||
|
||||
|
|
@ -843,9 +948,10 @@ def errors_detail():
|
|||
return redir
|
||||
path = request.args.get("path", "")
|
||||
data = _parse_404s()
|
||||
entry = data.get(path)
|
||||
if not entry:
|
||||
raw_entry = data.get(path)
|
||||
if not raw_entry:
|
||||
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()
|
||||
ip_list = []
|
||||
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"])
|
||||
def errors_ignore():
|
||||
redir = _require_admin()
|
||||
|
|
@ -887,20 +1009,32 @@ def errors_ignore():
|
|||
|
||||
@app.route("/errors/ban", methods=["POST"])
|
||||
def errors_ban():
|
||||
global _BANNED_CACHE_TS
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
ip = request.form.get("ip", "").strip()
|
||||
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):
|
||||
return jsonify({"error": "IP invalide"}), 400
|
||||
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(
|
||||
["/usr/bin/sudo", "/usr/bin/fail2ban-client", "set", jail, "banip", ip],
|
||||
capture_output=True, text=True, timeout=10
|
||||
["/usr/bin/sudo", "/usr/bin/fail2ban-client", "set", jail, "banip"] + targets,
|
||||
capture_output=True, text=True, timeout=60
|
||||
)
|
||||
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
|
||||
except Exception as e:
|
||||
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: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); }
|
||||
.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; }
|
||||
.col-err-status { width: 1.5rem; text-align: center; }
|
||||
|
|
|
|||
|
|
@ -80,9 +80,10 @@
|
|||
|
||||
<script>
|
||||
(function () {
|
||||
const DETAIL_URL = {{ url_for('errors_detail') | tojson }};
|
||||
const IGNORE_URL = {{ url_for('errors_ignore') | tojson }};
|
||||
const BAN_URL = {{ url_for('errors_ban') | tojson }};
|
||||
const DETAIL_URL = {{ url_for('errors_detail') | tojson }};
|
||||
const IGNORE_URL = {{ url_for('errors_ignore') | tojson }};
|
||||
const BAN_URL = {{ url_for('errors_ban') | tojson }};
|
||||
const ASINFO_URL = {{ url_for('errors_asinfo') | tojson }};
|
||||
|
||||
/* ── Search / filter ── */
|
||||
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 => {
|
||||
b.addEventListener('click', async () => {
|
||||
if (!confirm(`Bannir ${b.dataset.ip} dans fail2ban ?`)) return;
|
||||
b.disabled = true;
|
||||
b.textContent = '⏳';
|
||||
const fd = new FormData();
|
||||
fd.append('ip', b.dataset.ip);
|
||||
const r = await fetch(BAN_URL, { method: 'POST', body: fd });
|
||||
const j = await r.json();
|
||||
if (j.ok) {
|
||||
b.textContent = '✓';
|
||||
b.style.color = '#16a34a';
|
||||
} else {
|
||||
b.textContent = '✗';
|
||||
b.title = j.error || 'Erreur';
|
||||
b.style.color = '#dc2626';
|
||||
b.disabled = false;
|
||||
alert('Erreur : ' + (j.error || 'voir console'));
|
||||
}
|
||||
const ip = b.dataset.ip;
|
||||
const actionsDiv = b.closest('.row-actions');
|
||||
b.disabled = true; b.textContent = '⏳';
|
||||
|
||||
let asInfo = {};
|
||||
try {
|
||||
asInfo = await fetch(ASINFO_URL + '?ip=' + encodeURIComponent(ip)).then(r => r.json());
|
||||
} catch (_) {}
|
||||
|
||||
const asLabel = asInfo.asn
|
||||
? `AS${asInfo.asn} · ${escHtml(asInfo.name)}${asInfo.country ? ' [' + asInfo.country + ']' : ''}`
|
||||
: '<span style="color:var(--muted)">AS inconnu</span>';
|
||||
const n = asInfo.prefix_count || 0;
|
||||
|
||||
actionsDiv.innerHTML =
|
||||
`<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"
|
||||
|
||||
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_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 ───────────────────────────────────────────────────────
|
||||
DRY_RUN=false
|
||||
|
|
@ -51,9 +55,9 @@ echo -e " Source : ${CYAN}$REMOTE${RESET}"
|
|||
echo -e " Cible : ${CYAN}$LOCAL_DIR/${RESET}"
|
||||
echo ""
|
||||
|
||||
EXCLUDES=(--exclude='.git/' --exclude='scripts/' --exclude='README.md'
|
||||
--exclude='.env' --exclude='.env.example' --exclude='.gitignore'
|
||||
--exclude='wiki/')
|
||||
EXCLUDES=(--exclude='.git/' --exclude='scripts/' --exclude='app/'
|
||||
--exclude='README.md' --exclude='.env' --exclude='.env.example'
|
||||
--exclude='.gitignore')
|
||||
|
||||
DIFF=$(rsync -rlcz --dry-run --itemize-changes \
|
||||
--rsync-path="sudo rsync" \
|
||||
|
|
|
|||
|
|
@ -25,10 +25,14 @@ fi
|
|||
source "$ENV_FILE"
|
||||
|
||||
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_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 ───────────────────────────────────────────────────────
|
||||
DRY_RUN=false
|
||||
|
|
@ -55,11 +59,11 @@ echo -e " Source : ${CYAN}$LOCAL_DIR/${RESET}"
|
|||
echo -e " Cible : ${CYAN}$REMOTE${RESET}"
|
||||
echo ""
|
||||
|
||||
EXCLUDES=(--exclude='.git/' --exclude='scripts/' --exclude='README.md'
|
||||
--exclude='.env' --exclude='.env.example' --exclude='.gitignore'
|
||||
--exclude='wiki/')
|
||||
EXCLUDES=(--exclude='.git/' --exclude='scripts/' --exclude='app/'
|
||||
--exclude='README.md' --exclude='.env' --exclude='.env.example'
|
||||
--exclude='.gitignore')
|
||||
|
||||
DIFF=$(rsync -rlcz --dry-run --itemize-changes \
|
||||
DIFF=$(rsync -rlcz --dry-run --itemize-changes --delete \
|
||||
--rsync-path="sudo rsync" \
|
||||
"${EXCLUDES[@]}" \
|
||||
"$LOCAL_DIR/" "$REMOTE" 2>&1)
|
||||
|
|
@ -73,10 +77,10 @@ while IFS= read -r line; do
|
|||
if [[ "$item" == *"deleting"* ]]; then
|
||||
echo -e " ${RED}supprimé ${RESET} $file"
|
||||
DELETED=$(( DELETED + 1 ))
|
||||
elif [[ "$item" =~ ^\>f\+{6,} ]]; then
|
||||
elif [[ "$item" =~ ^\<f\+{6,} ]]; then
|
||||
echo -e " ${GREEN}nouveau ${RESET} $file"
|
||||
NEW=$(( NEW + 1 ))
|
||||
elif [[ "$item" =~ ^\>f ]]; then
|
||||
elif [[ "$item" =~ ^\<f ]]; then
|
||||
echo -e " ${YELLOW}modifié ${RESET} $file"
|
||||
CHANGED=$(( CHANGED + 1 ))
|
||||
fi
|
||||
|
|
@ -101,11 +105,23 @@ if ! $AUTO_YES; then
|
|||
[[ "$confirm" =~ ^[oOyY]$ ]] || { echo "Annulé."; exit 0; }
|
||||
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 ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BOLD}Synchronisation en cours…${RESET}"
|
||||
rsync -rlcz --human-readable --progress \
|
||||
--rsync-path="sudo rsync" \
|
||||
SYNC_FLAGS=(-rlcz --human-readable --progress --rsync-path="sudo rsync")
|
||||
$DELETE_REMOTE && SYNC_FLAGS+=(--delete)
|
||||
rsync "${SYNC_FLAGS[@]}" \
|
||||
"${EXCLUDES[@]}" \
|
||||
"$LOCAL_DIR/" "$REMOTE"
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue