Ctrl K
ComunidadGuías…

simple_nas/app/notfallblatt.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Emergency sheet ("Notfallblatt") for an external backup job.

A printable page with everything needed to get the data back WITHOUT Home
Assistant: where the data lives, the archive password (the one thing that
cannot be recovered anywhere else), and how to restore - with the Notfall-
Paket (notfall/ in the repository) or by hand with restic/rclone.

Provider secrets (box password, S3 secret, keys, a pasted rclone config) are
NOT printed: they can be reset at the provider. Only the archive password is,
because without it an encrypted backup is lost for good.

Pure function, no Flask: render(job, lang, now) -> HTML string.
"""
import copy
import html

import remote_backup as rb

REPO_URL = "https://github.com/gregorwolf1973/EasyNas-HASSIO-Addon"

PROVIDER_NAMES = {
    "de": {"hetzner": "Hetzner Storage Box", "sftp": "SFTP-Server", "s3": "S3-Speicher",
           "b2": "Backblaze B2", "webdav": "WebDAV", "smb": "SMB-Freigabe (NAS)",
           "local": "Lokaler Ordner / zweite Platte", "rclone": "eigene rclone-Konfiguration"},
    "en": {"hetzner": "Hetzner Storage Box", "sftp": "SFTP server", "s3": "S3 storage",
           "b2": "Backblaze B2", "webdav": "WebDAV", "smb": "SMB share (NAS)",
           "local": "Local folder / second disk", "rclone": "custom rclone config"},
}

FIELD_NAMES_EN = {
    "host": "Server", "port": "Port", "user": "User", "s3_provider": "S3 provider",
    "endpoint": "Endpoint", "region": "Region", "access_key": "Access key", "bucket": "Bucket",
    "b2_account": "Key ID", "url": "URL", "vendor": "Vendor", "domain": "Domain",
    "share": "Share", "path": "Folder",
}

# Characters that are easily mixed up when copying by hand.
AMBIGUOUS = {
    "de": {"l": "klein L", "I": "groß i", "1": "Eins", "|": "Strich", "O": "groß O", "o": "klein o",
           "0": "Null", " ": "Leerzeichen", "`": "Gravis", "'": "Apostroph", "´": "Akut",
           "-": "Minus", "_": "Unterstrich", ".": "Punkt", ",": "Komma"},
    "en": {"l": "lower L", "I": "upper i", "1": "one", "|": "bar", "O": "upper O", "o": "lower o",
           "0": "zero", " ": "space", "`": "backtick", "'": "apostrophe", "´": "acute",
           "-": "minus", "_": "underscore", ".": "dot", ",": "comma"},
}

TEXT = {
    "de": {
        "title": "Notfallblatt", "made": "Erstellt am {d} von Simple NAS · Job-ID {id}",
        "print": "Drucken",
        "confidential": "<b>Vertraulich.</b> Wer dieses Blatt und den Zugang zum Speicher hat, kann alle "
                        "gesicherten Daten lesen. Ausdrucken und sicher aufbewahren – <b>nicht</b> auf dem NAS "
                        "oder in Home Assistant ablegen. Nach jeder Änderung am Job neu drucken.",
        "where": "1. Wo liegen die Daten", "engine": "Verfahren", "provider": "Anbieter",
        "remote_path": "Pfad auf dem Ziel", "provider_pw": "Passwort / Zugang des Anbieters",
        "provider_pw_hetzner": "steht bewusst nicht hier – vergessen? In der Hetzner Console "
                               "(Storage Box → Passwort zurücksetzen) neu setzen, die Daten bleiben erhalten.",
        "provider_pw_other": "steht bewusst nicht hier – beim Anbieter neu setzen bzw. neuen Schlüssel erzeugen.",
        "handwrite": "von Hand eintragen, falls gewünscht:",
        "engine_restic": "restic – verschlüsseltes Archiv mit Sicherungsständen",
        "engine_rclone": "rclone – Dateien 1:1 beim Anbieter",
        "engine_rclone_crypt": "rclone – Dateien 1:1 beim Anbieter, verschlüsselt (rclone crypt)",
        "pw": "2. Archiv-Passwort", "pw_warn": "<b>Ohne dieses Passwort sind die Daten verloren</b> – "
               "auch Hetzner bzw. der Anbieter kann sie nicht entschlüsseln.",
        "pw_none": "Dieser Job verschlüsselt nicht – es gibt kein Archiv-Passwort.",
        "pw_chars": "{n} Zeichen. Unter verwechselbaren Zeichen steht, was gemeint ist.",
        "what": "3. Was gesichert wird", "sources": "Quellordner", "last": "Letztes Backup",
        "never": "noch keines", "snap_host": "restic-Host / Tag der Stände",
        "auto": "Wird nur Home Assistant neu installiert (gleiche Hardware, /config erhalten), kommen die "
                "Jobs samt Passwort mit dem Add-on zurück (Kopie unter /config/.simplenas/auto).",
        "restore": "4. Wiederherstellen ohne Home Assistant",
        "pkg_title": "Mit dem Notfall-Paket (Windows oder Linux)",
        "pkg_steps": "ZIP <code>SimpleNAS-Notfall-…zip</code> auspacken (liegt auf USB-Stick o. Ä.), "
                     "unter Windows <code>WIEDERHERSTELLEN-Windows.cmd</code> doppelklicken, unter Linux "
                     "<code>bash wiederherstellen-linux.sh</code>. Das Tool fragt:",
        "pkg_box": "Storage Box", "pkg_user": "Benutzer", "pkg_port": "Port", "pkg_path": "Pfad auf der Box",
        "pkg_boxpw": "Passwort der Storage Box", "pkg_archpw": "Archiv-Passwort", "pkg_see": "siehe 2.",
        "pkg_boxpw_see": "nicht auf dem Blatt – siehe 1.",
        "local_copy": "Der Ordner {p} enthält die Dateien direkt – einfach kopieren (ohne {v}).",
        "pkg_where": "Kein Paket zur Hand? Es lässt sich aus dem Repository neu bauen: {u} → Ordner "
                     "<code>notfall/</code>, <code>bash notfall/paket-bauen.sh</code>.",
        "pkg_only": "Das Notfall-Paket kennt nur restic auf einer Hetzner Storage Box bzw. einem SFTP-Server "
                    "mit Passwort – für dieses Ziel bitte den Weg von Hand nehmen.",
        "manual_title": "Von Hand mit restic und rclone",
        "manual_intro": "restic und rclone von restic.net bzw. rclone.org holen. Diese Konfiguration als "
                        "Datei <code>notfall.conf</code> speichern; Werte in ‹…› ersetzen – Passwörter vorher "
                        "mit <code>rclone obscure PASSWORT</code> umwandeln:",
        "manual_cmds": "Dann (Windows: <code>set</code> statt <code>export</code>):",
        "manual_custom": "Dieser Job nutzt eine eigene rclone-Konfiguration. Sie steht nicht auf dem Blatt – "
                         "bitte separat aufbewahren. Der Abschnitt darin heißt im Add-on <code>{s}</code>.",
        "ph_pw": "rclone obscure: Passwort des Anbieters", "ph_archpw": "rclone obscure: Archiv-Passwort (2.)",
        "ph_secret": "geheim – beim Anbieter nachsehen", "ph_key": "privater Schlüssel, eine Zeile mit \\n",
        "target_dir": "wiederhergestellt",
    },
    "en": {
        "title": "Emergency sheet", "made": "Created {d} by Simple NAS · job ID {id}",
        "print": "Print",
        "confidential": "<b>Confidential.</b> Anyone holding this sheet and access to the storage can read all "
                        "backed-up data. Print it and keep it safe – <b>not</b> on the NAS or in Home Assistant. "
                        "Print again after every change to the job.",
        "where": "1. Where the data is", "engine": "Method", "provider": "Provider",
        "remote_path": "Path on the target", "provider_pw": "Provider password / access",
        "provider_pw_hetzner": "deliberately not printed – forgotten? Reset it in the Hetzner Console "
                               "(Storage Box → reset password); the data is kept.",
        "provider_pw_other": "deliberately not printed – reset it or create a new key at the provider.",
        "handwrite": "write it in by hand if you like:",
        "engine_restic": "restic – encrypted archive with snapshots",
        "engine_rclone": "rclone – files 1:1 at the provider",
        "engine_rclone_crypt": "rclone – files 1:1 at the provider, encrypted (rclone crypt)",
        "pw": "2. Archive password", "pw_warn": "<b>Without this password the data is lost</b> – "
               "Hetzner or the provider cannot decrypt it either.",
        "pw_none": "This job does not encrypt – there is no archive password.",
        "pw_chars": "{n} characters. Easily confused characters are explained below them.",
        "what": "3. What is backed up", "sources": "Source folders", "last": "Last backup",
        "never": "none yet", "snap_host": "restic host / tag of the snapshots",
        "auto": "If only Home Assistant is reinstalled (same hardware, /config kept), the jobs and the "
                "password come back with the add-on (copy in /config/.simplenas/auto).",
        "restore": "4. Restoring without Home Assistant",
        "pkg_title": "With the emergency package (Windows or Linux)",
        "pkg_steps": "Unpack <code>SimpleNAS-Notfall-…zip</code> (kept on a USB stick or similar); on Windows "
                     "double-click <code>WIEDERHERSTELLEN-Windows.cmd</code>, on Linux run "
                     "<code>bash wiederherstellen-linux.sh</code>. The tool asks for:",
        "pkg_box": "Storage Box", "pkg_user": "User", "pkg_port": "Port", "pkg_path": "Path on the box",
        "pkg_boxpw": "Storage Box password", "pkg_archpw": "Archive password", "pkg_see": "see 2.",
        "pkg_boxpw_see": "not on this sheet – see 1.",
        "local_copy": "The folder {p} holds the files as they are – just copy them (without {v}).",
        "pkg_where": "No package at hand? Rebuild it from the repository: {u} → folder <code>notfall/</code>, "
                     "<code>bash notfall/paket-bauen.sh</code>.",
        "pkg_only": "The emergency package only handles restic on a Hetzner Storage Box or an SFTP server "
                    "with a password – use the manual way for this target.",
        "manual_title": "By hand with restic and rclone",
        "manual_intro": "Get restic and rclone from restic.net and rclone.org. Save this configuration as "
                        "<code>notfall.conf</code>; replace the values in ‹…› – convert passwords with "
                        "<code>rclone obscure PASSWORD</code> first:",
        "manual_cmds": "Then (Windows: <code>set</code> instead of <code>export</code>):",
        "manual_custom": "This job uses a custom rclone configuration. It is not printed here – keep it "
                         "separately. Its section is called <code>{s}</code> in the add-on.",
        "ph_pw": "rclone obscure: provider password", "ph_archpw": "rclone obscure: archive password (2.)",
        "ph_secret": "secret – look it up at the provider", "ph_key": "private key, one line with \\n",
        "target_dir": "restored",
    },
}

CSS = """
*{box-sizing:border-box}
body{font:14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#111;background:#fff;
  max-width:820px;margin:24px auto;padding:0 16px}
h1{font-size:24px;margin:0 0 2px}h2{font-size:16px;margin:22px 0 8px;border-bottom:2px solid #111;padding-bottom:3px}
h3{font-size:14px;margin:14px 0 6px}
.sub{color:#555;font-size:12.5px}
.box{border:1.5px solid #b00;background:#fff5f5;padding:8px 12px;margin:12px 0;border-radius:4px}
table.kv{border-collapse:collapse;width:100%}table.kv td{border-bottom:1px solid #ddd;padding:5px 6px;vertical-align:top}
table.kv td:first-child{width:34%;color:#444}
.mono,code,pre{font-family:Consolas,"DejaVu Sans Mono",Menlo,monospace}
pre{background:#f4f4f4;border:1px solid #ddd;padding:8px 10px;white-space:pre-wrap;word-break:break-all;font-size:12.5px}
.pw{display:flex;flex-wrap:wrap;gap:3px;margin:8px 0}
.pw span{display:inline-flex;flex-direction:column;align-items:center;min-width:30px;border:1px solid #999;padding:3px 2px 2px}
.pw b{font:600 20px Consolas,"DejaVu Sans Mono",Menlo,monospace;white-space:pre}
.pw i{font-size:10.5px;color:#555;font-style:normal;line-height:1.1;text-align:center;min-height:10px}
.pw small{font-size:9px;color:#999}
.line{display:inline-block;min-width:260px;border-bottom:1px solid #111;height:1.2em}
button{font:inherit;padding:6px 16px;cursor:pointer}
@media print{button{display:none}body{margin:0;max-width:none}h2{break-after:avoid}pre,.pw,table{break-inside:avoid}}
"""


def _e(s):
    return html.escape(str(s if s is not None else ""), quote=True)


def _password_cells(pw, lang):
    amb = AMBIGUOUS[lang]
    cells = []
    for i, ch in enumerate(pw, 1):
        cells.append(f"<span><b>{_e(ch)}</b><i>{_e(amb.get(ch, ''))}</i><small>{i}</small></span>")
    return '<div class="pw">' + "".join(cells) + "</div>"


def _masked_job(job, tx):
    """Copy of the job with every secret replaced by a placeholder and the
    section names shortened to t_nas / c_nas."""
    j = copy.deepcopy(job)
    j["id"] = "nas"
    t = j.setdefault("target", {})
    for f in ("password",):
        if t.get(f):
            t[f] = "‹" + tx["ph_pw"] + "›"
    if t.get("key_pem"):
        t["key_pem"] = "‹" + tx["ph_key"] + "›"
    for f in ("secret_key", "b2_key"):
        if t.get(f):
            t[f] = "‹" + tx["ph_secret"] + "›"
    if j.get("password"):
        j["password"] = "‹" + tx["ph_archpw"] + "›"
    return j


def _manual_block(job, tx):
    prov = (job.get("target") or {}).get("provider")
    out = [f"<h3>{_e(tx['manual_title'])}</h3>"]
    target_dir = tx["target_dir"]
    engine = job.get("engine")
    sources = job.get("sources") or []
    include = f"restore latest --target ./{target_dir} --include {sources[0]}" if sources else ""
    if prov == "local" and engine == "restic":
        # A local repository needs no rclone at all.
        path = rb._base(job)
        cmds = (f"restic -r \"{path}\" snapshots\n"
                f"restic -r \"{path}\" restore latest --target ./{target_dir}"
                + (f"\nrestic -r \"{path}\" {include}" if include else ""))
        out.append(f"<pre>{_e(cmds)}</pre>")
        return "".join(out)
    if prov == "local" and not job.get("encrypt"):
        out.append(f"<p>{tx['local_copy'].format(p='<code>' + _e(rb._base(job)) + '</code>', v=_e(rb.VERSIONS_DIR))}</p>")
        return "".join(out)
    if prov == "rclone":
        out.append(f"<p>{tx['manual_custom'].format(s=_e('t_' + str(job.get('id', ''))))}</p>")
        return "".join(out)
    m = _masked_job(job, tx)
    conf = rb.build_rclone_conf(m, obscure=lambda s: s).strip()
    out.append(f"<p>{tx['manual_intro']}</p><pre>{_e(conf)}</pre><p>{tx['manual_cmds']}</p>")
    if engine == "restic":
        repo = rb.restic_repo(m)
        cmds = ("export RCLONE_CONFIG=notfall.conf\n"
                f"restic -r {repo} snapshots\n"
                f"restic -r {repo} ls latest /\n"
                f"restic -r {repo} restore latest --target ./{target_dir}"
                + (f"\nrestic -r {repo} {include}" if include else ""))
    else:
        root = rb.rclone_root(m)
        cmds = ("export RCLONE_CONFIG=notfall.conf\n"
                f"rclone lsd {root}\n"
                f"rclone copy {root} ./{target_dir} --exclude \"/{rb.VERSIONS_DIR}/**\" --progress")
    out.append(f"<pre>{_e(cmds)}</pre>")
    return "".join(out)


def _package_supported(job):
    t = job.get("target") or {}
    if job.get("engine") != "restic":
        return False
    if t.get("provider") == "hetzner":
        return True
    return t.get("provider") == "sftp" and bool(t.get("password")) and not t.get("key_pem")


def render(job, lang="de", now=None):
    lang = lang if lang in TEXT else "de"
    tx = TEXT[lang]
    t = job.get("target") or {}
    prov = t.get("provider", "")
    engine = job.get("engine", "")
    when = (now.strftime("%d.%m.%Y %H:%M") if lang == "de" else now.strftime("%Y-%m-%d %H:%M")) if now else ""

    def label(f):
        return rb.FIELD_LABELS.get(f, f) if lang == "de" else FIELD_NAMES_EN.get(f, f)

    rows = [(tx["engine"], tx["engine_restic"] if engine == "restic"
             else tx["engine_rclone_crypt"] if job.get("encrypt") else tx["engine_rclone"]),
            (tx["provider"], PROVIDER_NAMES[lang].get(prov, prov))]
    for f in rb.TARGET_FIELDS.get(prov, ()):
        if f in rb.SECRET_TARGET_FIELDS:
            continue
        v = t.get(f)
        if f == "port" and not v and prov in ("hetzner", "sftp"):
            v = 22
        if v not in (None, ""):
            rows.append((label(f), v))
    if prov != "local":
        rows.append((tx["remote_path"], job.get("remote_path") or "/"))
    kv = "".join(f'<tr><td>{_e(k)}</td><td class="mono">{_e(v)}</td></tr>' for k, v in rows)
    if prov not in ("local",):
        note = tx["provider_pw_hetzner"] if prov == "hetzner" else tx["provider_pw_other"]
        kv += (f'<tr><td>{_e(tx["provider_pw"])}</td><td>{_e(note)}<br><span class="sub">'
               f'{_e(tx["handwrite"])}</span> <span class="line"></span></td></tr>')

    pw = job.get("password") or ""
    has_pw = bool(pw) and (engine == "restic" or job.get("encrypt"))
    if has_pw:
        pw_html = (f'<div class="box">{tx["pw_warn"]}</div>'
                   f'<pre style="font-size:18px">{_e(pw)}</pre>'
                   f'{_password_cells(pw, lang)}<div class="sub">{_e(tx["pw_chars"].format(n=len(pw)))}</div>')
    else:
        pw_html = f"<p>{_e(tx['pw_none'])}</p>"

    last = (job.get("last_backup") or {})
    last_txt = f"{last.get('at', '').replace('T', ' ')} ({last.get('status', '')})" if last.get("at") else tx["never"]
    what = [(tx["sources"], "\n".join(job.get("sources") or [])), (tx["last"], last_txt)]
    if engine == "restic":
        what.append((tx["snap_host"], f"{rb.RESTIC_HOST} / {rb.RESTIC_TAG}"))
    what_html = "".join(f'<tr><td>{_e(k)}</td><td class="mono" style="white-space:pre-line">{_e(v)}</td></tr>'
                        for k, v in what)

    if _package_supported(job):
        port = t.get("port") or 22
        pkg_rows = [(tx["pkg_box"], t.get("host", "")), (tx["pkg_user"], t.get("user", "")),
                    (tx["pkg_port"], port), (tx["pkg_path"], job.get("remote_path") or "/")]
        pkg = "".join(f'<tr><td>{_e(k)}</td><td class="mono">{_e(v)}</td></tr>' for k, v in pkg_rows)
        pkg += (f'<tr><td>{_e(tx["pkg_boxpw"])}</td><td>{_e(tx["pkg_boxpw_see"])}</td></tr>'
                f'<tr><td>{_e(tx["pkg_archpw"])}</td><td>{_e(tx["pkg_see"])}</td></tr>')
        pkg_html = (f"<h3>{_e(tx['pkg_title'])}</h3><p>{tx['pkg_steps']}</p><table class=\"kv\">{pkg}</table>"
                    f"<p class=\"sub\">{tx['pkg_where'].format(u=_e(REPO_URL))}</p>")
    else:
        pkg_html = f"<p class=\"sub\">{_e(tx['pkg_only'])}</p>"

    name = job.get("name", "")
    return f"""<!doctype html>
<html lang="{lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>{_e(tx['title'])} – {_e(name)}</title><style>{CSS}</style></head><body>
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px">
<div><h1>{_e(tx['title'])} – {_e(name)}</h1>
<div class="sub">{_e(tx['made'].format(d=when, id=job.get('id', '')))}</div></div>
<button onclick="window.print()">{_e(tx['print'])}</button></div>
<div class="box">{tx['confidential']}</div>
<h2>{_e(tx['where'])}</h2><table class="kv">{kv}</table>
<h2>{_e(tx['pw'])}</h2>{pw_html}
<h2>{_e(tx['what'])}</h2><table class="kv">{what_html}</table><p class="sub">{_e(tx['auto'])}</p>
<h2>{_e(tx['restore'])}</h2>{pkg_html}{_manual_block(job, tx)}
</body></html>
"""