feat(erreurs): ban AS entier + masquer IPs bannies (#43, #44)

- 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:
Alpinux 2026-05-06 13:31:35 +02:00
parent 3a6f363e1d
commit 5d2a4ab430
7 changed files with 251 additions and 44 deletions

View file

@ -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é

View file

@ -1 +1 @@
1.5.2 1.6.0

View file

@ -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

View file

@ -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; }

View file

@ -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>`;
}
});
});
}); });
}); });
} }

View file

@ -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" \

View file

@ -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"