feat: SSH-Key-Auth als primäre Methode, Bitwarden als Fallback
- Neuer Step I (ssh_key_versuch.py): liest SSH-Keys aus DB, testet Verbindung per paramiko; erfolgreiche Server landen in server_creds, fehlgeschlagene in needs_bitwarden - Step G (Bitwarden) ist jetzt No-Op wenn alle Server per Key OK - paramiko.DSSKey in allen 4 Dateien entfernt (nicht in paramiko 4.0) - failure_module (flow_fehler_handler.py): sendet bei jedem Flow-Fehler eine Nextcloud-Talk-Nachricht und bereinigt DB/Session - Bitwarden-Step überspringt fehlgeschlagene Server statt abzubrechen - testpause.py als wiederverwendbarer Debug-Helper behalten Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,32 @@
|
||||
import wmill, json, paramiko, mysql.connector
|
||||
import wmill, json, paramiko, io, mysql.connector
|
||||
|
||||
|
||||
def _load_pkey(key_str: str):
|
||||
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
|
||||
try:
|
||||
return cls.from_private_key(io.StringIO(key_str))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _ssh_connect(ip: str, user: str, password: str = "", private_key: str = "") -> paramiko.SSHClient:
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
if private_key:
|
||||
ssh.connect(ip, username=user, pkey=_load_pkey(private_key))
|
||||
else:
|
||||
ssh.connect(ip, username=user, password=password)
|
||||
return ssh
|
||||
|
||||
|
||||
def start_restore(server, backup, job_uuid, webhook_url, webhook_tok):
|
||||
"""Startet restore.sh auf einem Server non-blocking."""
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(
|
||||
creds = server["ssh_creds"]
|
||||
ssh = _ssh_connect(
|
||||
server["ip"],
|
||||
username=server["ssh_creds"]["user"],
|
||||
password=server["ssh_creds"]["password"]
|
||||
creds["user"],
|
||||
creds.get("password", ""),
|
||||
creds.get("private_key", ""),
|
||||
)
|
||||
safe_client = backup["client_name"].replace("/", "_").replace(":", "_")
|
||||
srv_hostname = server["hostname"]
|
||||
|
||||
@@ -45,8 +45,19 @@ value:
|
||||
expr: results.a
|
||||
lock: '!inline alle_freien_restore-server_holen.lock'
|
||||
language: python3
|
||||
- id: i
|
||||
summary: SSH-Key aus DB testen
|
||||
value:
|
||||
type: rawscript
|
||||
content: '!inline ssh_key_versuch.py'
|
||||
input_transforms:
|
||||
prev:
|
||||
type: javascript
|
||||
expr: results.b
|
||||
lock: '!inline ssh_key_versuch.lock'
|
||||
language: python3
|
||||
- id: g
|
||||
summary: SSH-Credentials fuer alle Restore-Server aus Bitwarden
|
||||
summary: SSH-Credentials fuer alle Restore-Server aus Bitwarden (Fallback)
|
||||
value:
|
||||
type: rawscript
|
||||
content: '!inline ssh-credentials_fuer_alle_restore-server_aus_bitwarden.py'
|
||||
@@ -56,7 +67,7 @@ value:
|
||||
value: https://bitwarden.stines.de
|
||||
prev:
|
||||
type: javascript
|
||||
expr: results.b
|
||||
expr: results.i
|
||||
lock: '!inline ssh-credentials_fuer_alle_restore-server_aus_bitwarden.lock'
|
||||
language: python3
|
||||
- id: c
|
||||
@@ -114,6 +125,21 @@ value:
|
||||
lock: '!inline
|
||||
webhook_verarbeiten_&_naechsten_restore_auf_demselben_server_starten.lock'
|
||||
language: python3
|
||||
failure_module:
|
||||
id: failure
|
||||
summary: Flow-Fehler per Nextcloud Talk melden
|
||||
value:
|
||||
type: rawscript
|
||||
content: '!inline flow_fehler_handler.py'
|
||||
input_transforms:
|
||||
error:
|
||||
type: javascript
|
||||
expr: error
|
||||
flow_input:
|
||||
type: javascript
|
||||
expr: flow_input
|
||||
lock: '!inline flow_fehler_handler.lock'
|
||||
language: python3
|
||||
schema:
|
||||
$schema: https://json-schema.org/draft/2020-12/schema
|
||||
type: object
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# py: 3.12
|
||||
anyio==4.12.1
|
||||
bcrypt==5.0.0
|
||||
certifi==2026.2.25
|
||||
cffi==2.0.0
|
||||
cryptography==46.0.5
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
invoke==2.2.1
|
||||
mysql-connector-python==9.6.0
|
||||
paramiko==4.0.0
|
||||
pycparser==3.0
|
||||
pynacl==1.6.2
|
||||
typing-extensions==4.15.0
|
||||
wmill==1.657.2
|
||||
@@ -0,0 +1,75 @@
|
||||
import wmill, base64, httpx, json, mysql.connector
|
||||
|
||||
def _send_talk(message: str):
|
||||
try:
|
||||
nc_url = wmill.get_variable("f/Backup/nextcloud_talk_url").rstrip("/")
|
||||
nc_room = wmill.get_variable("f/Backup/nextcloud_talk_room")
|
||||
nc_user = wmill.get_variable("f/Backup/nextcloud_talk_user")
|
||||
nc_password = wmill.get_variable("f/Backup/nextcloud_talk_password")
|
||||
credentials = base64.b64encode(f"{nc_user}:{nc_password}".encode()).decode()
|
||||
httpx.post(
|
||||
f"{nc_url}/ocs/v2.php/apps/spreed/api/v1/chat/{nc_room}",
|
||||
headers={
|
||||
"Authorization": f"Basic {credentials}",
|
||||
"OCS-APIREQUEST": "true",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
json={"message": message},
|
||||
timeout=15,
|
||||
verify=False,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Talk-Fehler (nicht kritisch): {e}")
|
||||
|
||||
def main(error: dict, flow_input: dict = {}):
|
||||
msg = error.get("message", "Unbekannter Fehler")
|
||||
name = error.get("name", "")
|
||||
step_id = error.get("step_id", "")
|
||||
|
||||
# Laufenden Job in DB als failed markieren und Server freigeben
|
||||
try:
|
||||
db_cfg = json.loads(wmill.get_variable("f/Backup/mysql_config"))
|
||||
conn = mysql.connector.connect(**db_cfg)
|
||||
cur = conn.cursor(dictionary=True)
|
||||
|
||||
cur.execute("""
|
||||
SELECT job_uuid FROM Kunden.`bronze.restore.jobs`
|
||||
WHERE status = 'running'
|
||||
LIMIT 1
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
job_uuid = row["job_uuid"]
|
||||
cur.execute("""
|
||||
UPDATE Kunden.`bronze.restore.jobs`
|
||||
SET status = 'failed', finished_at = NOW()
|
||||
WHERE job_uuid = %s
|
||||
""", (job_uuid,))
|
||||
cur.execute("""
|
||||
UPDATE Kunden.`bronze.restore.server`
|
||||
SET current_job_uuid = NULL
|
||||
WHERE current_job_uuid = %s
|
||||
""", (job_uuid,))
|
||||
cur.execute("""
|
||||
DELETE FROM Kunden.`bronze.restore.session`
|
||||
WHERE job_uuid = %s
|
||||
""", (job_uuid,))
|
||||
conn.commit()
|
||||
print(f"DB: Job {job_uuid} als failed markiert, Server freigegeben.")
|
||||
|
||||
cur.close(); conn.close()
|
||||
except Exception as e:
|
||||
print(f"DB-Cleanup fehlgeschlagen (nicht kritisch): {e}")
|
||||
|
||||
step_line = f"\nStep: `{step_id}`" if step_id else ""
|
||||
type_line = f"\nTyp: {name}" if name else ""
|
||||
talk_msg = (
|
||||
f"🚨 **Backup Restore Flow Fehler**{step_line}\n"
|
||||
f"Fehler: {msg[:300]}"
|
||||
f"{type_line}"
|
||||
)
|
||||
|
||||
_send_talk(talk_msg)
|
||||
print(f"Talk-Nachricht gesendet:\n{talk_msg}")
|
||||
return {"notified": True}
|
||||
+32
-10
@@ -1,5 +1,23 @@
|
||||
import wmill, json, paramiko, io, mysql.connector, re
|
||||
|
||||
def _load_pkey(key_str: str):
|
||||
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
|
||||
try:
|
||||
return cls.from_private_key(io.StringIO(key_str))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _ssh_connect(ip: str, user: str, password: str = "", private_key: str = "") -> paramiko.SSHClient:
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
if private_key:
|
||||
pkey = _load_pkey(private_key)
|
||||
ssh.connect(ip, username=user, pkey=pkey)
|
||||
else:
|
||||
ssh.connect(ip, username=user, password=password)
|
||||
return ssh
|
||||
|
||||
GITEA_REPO = "http://172.17.1.251:8080/sebastian.serfling/BackupScript.git"
|
||||
|
||||
|
||||
@@ -147,14 +165,16 @@ def deploy_to_server(ssh, server, pbs, pbs_host, pbs_user, pbs_pass,
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO Kunden.`bronze.restore.session`
|
||||
(job_uuid, hostname, ip, ssh_user, ssh_password)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
(job_uuid, hostname, ip, ssh_user, ssh_password, ssh_private_key)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ip=VALUES(ip), ssh_user=VALUES(ssh_user),
|
||||
ssh_password=VALUES(ssh_password)
|
||||
ssh_password=VALUES(ssh_password),
|
||||
ssh_private_key=VALUES(ssh_private_key)
|
||||
""", (
|
||||
job_uuid, hostname,
|
||||
ssh_creds["ip"], ssh_creds["user"], ssh_creds["password"],
|
||||
ssh_creds.get("private_key", "") or "",
|
||||
))
|
||||
|
||||
conn.commit(); cur.close(); conn.close()
|
||||
@@ -207,16 +227,18 @@ def main(prev: dict, bw_result: dict = {}, datastores: list = []):
|
||||
print(f"WARNUNG: Keine IP fuer '{hostname}' – uebersprungen.")
|
||||
continue
|
||||
|
||||
ssh_user = creds.get("username", "root")
|
||||
ssh_password = creds.get("password", "")
|
||||
ssh_private_key = creds.get("private_key", "")
|
||||
|
||||
ssh_creds_dict = {
|
||||
"ip": ip,
|
||||
"user": creds.get("username", "root"),
|
||||
"password": creds.get("password", ""),
|
||||
"ip": ip,
|
||||
"user": ssh_user,
|
||||
"password": ssh_password,
|
||||
"private_key": ssh_private_key,
|
||||
}
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(ip, username=ssh_creds_dict["user"],
|
||||
password=ssh_creds_dict["password"])
|
||||
ssh = _ssh_connect(ip, ssh_user, ssh_password, ssh_private_key)
|
||||
|
||||
try:
|
||||
pbs_storages = deploy_to_server(
|
||||
|
||||
+28
-5
@@ -34,7 +34,13 @@ def main(
|
||||
if prev.get("mode") == "webhook":
|
||||
return prev
|
||||
|
||||
servers = prev.get("target_servers", [])
|
||||
needs_bitwarden = prev.get("needs_bitwarden", [])
|
||||
if not needs_bitwarden:
|
||||
print("Alle Server per SSH-Key authentifiziert – Bitwarden wird nicht benötigt.")
|
||||
return prev
|
||||
|
||||
servers = [s for s in prev.get("target_servers", [])
|
||||
if s["hostname"] in needs_bitwarden]
|
||||
|
||||
bw_creds = json.loads(wmill.get_variable("f/Backup/bitwarden_api_login"))
|
||||
env = os.environ.copy()
|
||||
@@ -69,14 +75,31 @@ def main(
|
||||
raise Exception("Vault konnte nicht entsperrt werden")
|
||||
env["BW_SESSION"] = bw_session
|
||||
|
||||
server_creds = {}
|
||||
server_creds = prev.get("server_creds", {})
|
||||
failed_servers = []
|
||||
|
||||
for server in servers:
|
||||
hostname = server["hostname"]
|
||||
print(f"Hole Creds fuer: {hostname}")
|
||||
creds = bw_lookup(hostname, env, run)
|
||||
server_creds[hostname] = creds
|
||||
print(f" -> OK: {creds['username']}@{hostname}")
|
||||
try:
|
||||
creds = bw_lookup(hostname, env, run)
|
||||
server_creds[hostname] = creds
|
||||
print(f" -> OK: {creds['username']}@{hostname}")
|
||||
except Exception as e:
|
||||
print(f" -> WARNUNG: {e} – Server wird übersprungen")
|
||||
failed_servers.append(hostname)
|
||||
|
||||
run(["bw", "logout"], check=False)
|
||||
|
||||
if failed_servers:
|
||||
print(f"\nWARNUNG: Keine Credentials für: {failed_servers}")
|
||||
# Server aus target_servers entfernen damit C/D sie nicht anfassen
|
||||
remaining = [s for s in prev.get("target_servers", [])
|
||||
if s["hostname"] not in failed_servers]
|
||||
if not remaining:
|
||||
raise Exception(
|
||||
f"Keine Restore-Server verfügbar – Bitwarden-Lookup fehlgeschlagen für: {failed_servers}"
|
||||
)
|
||||
return {**prev, "server_creds": server_creds, "target_servers": remaining}
|
||||
|
||||
return {**prev, "server_creds": server_creds}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# py: 3.12
|
||||
anyio==4.12.1
|
||||
bcrypt==5.0.0
|
||||
certifi==2026.2.25
|
||||
cffi==2.0.0
|
||||
cryptography==46.0.5
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
invoke==2.2.1
|
||||
mysql-connector-python==9.6.0
|
||||
paramiko==4.0.0
|
||||
pycparser==3.0
|
||||
pynacl==1.6.2
|
||||
typing-extensions==4.15.0
|
||||
wmill==1.657.2
|
||||
@@ -0,0 +1,81 @@
|
||||
import wmill, mysql.connector, json, paramiko, io
|
||||
|
||||
def _load_pkey(key_str: str):
|
||||
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
|
||||
try:
|
||||
return cls.from_private_key(io.StringIO(key_str))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def main(prev: dict):
|
||||
if prev.get("mode") == "webhook":
|
||||
return prev
|
||||
|
||||
servers = prev.get("target_servers", [])
|
||||
db_cfg = json.loads(wmill.get_variable("f/Backup/mysql_config"))
|
||||
conn = mysql.connector.connect(**db_cfg)
|
||||
cur = conn.cursor(dictionary=True)
|
||||
|
||||
hostnames = [s["hostname"] for s in servers]
|
||||
placeholders = ",".join(["%s"] * len(hostnames))
|
||||
try:
|
||||
cur.execute(f"""
|
||||
SELECT hostname, ip, ssh_private_key, ssh_key_user
|
||||
FROM Kunden.`bronze.restore.server`
|
||||
WHERE hostname IN ({placeholders})
|
||||
AND ssh_private_key IS NOT NULL
|
||||
AND ssh_private_key != ''
|
||||
""", hostnames)
|
||||
key_rows = {row["hostname"]: row for row in cur.fetchall()}
|
||||
except Exception as e:
|
||||
print(f"WARNUNG: SSH-Key-Spalten nicht in DB vorhanden ({e})")
|
||||
print("Alle Server werden über Bitwarden authentifiziert.")
|
||||
key_rows = {}
|
||||
cur.close(); conn.close()
|
||||
|
||||
server_creds = {}
|
||||
needs_bitwarden = []
|
||||
|
||||
for server in servers:
|
||||
hostname = server["hostname"]
|
||||
ip = server.get("ip", "")
|
||||
|
||||
if hostname not in key_rows:
|
||||
print(f"[{hostname}] Kein SSH-Key in DB → Bitwarden")
|
||||
needs_bitwarden.append(hostname)
|
||||
continue
|
||||
|
||||
row = key_rows[hostname]
|
||||
key_str = row["ssh_private_key"]
|
||||
user = row.get("ssh_key_user") or "root"
|
||||
actual_ip = row.get("ip") or ip
|
||||
|
||||
pkey = _load_pkey(key_str)
|
||||
if pkey is None:
|
||||
print(f"[{hostname}] Key-Format nicht erkannt → Bitwarden")
|
||||
needs_bitwarden.append(hostname)
|
||||
continue
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
ssh.connect(actual_ip, username=user, pkey=pkey,
|
||||
timeout=5, auth_timeout=5)
|
||||
ssh.close()
|
||||
print(f"[{hostname}] SSH-Key Auth OK ✓ ({user}@{actual_ip})")
|
||||
server_creds[hostname] = {
|
||||
"username": user,
|
||||
"password": "",
|
||||
"private_key": key_str,
|
||||
"auth_method": "key",
|
||||
"url": f"https://{actual_ip}",
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"[{hostname}] SSH-Key fehlgeschlagen: {e} → Bitwarden")
|
||||
needs_bitwarden.append(hostname)
|
||||
|
||||
print(f"Key-Auth OK: {list(server_creds.keys())}")
|
||||
print(f"Braucht Bitwarden: {needs_bitwarden}")
|
||||
|
||||
return {**prev, "server_creds": server_creds, "needs_bitwarden": needs_bitwarden}
|
||||
@@ -0,0 +1,17 @@
|
||||
# py: 3.12
|
||||
anyio==4.12.1
|
||||
bcrypt==5.0.0
|
||||
certifi==2026.2.25
|
||||
cffi==2.0.0
|
||||
cryptography==46.0.5
|
||||
h11==0.16.0
|
||||
httpcore==1.0.9
|
||||
httpx==0.28.1
|
||||
idna==3.11
|
||||
invoke==2.2.1
|
||||
mysql-connector-python==9.6.0
|
||||
paramiko==4.0.0
|
||||
pycparser==3.0
|
||||
pynacl==1.6.2
|
||||
typing-extensions==4.15.0
|
||||
wmill==1.657.2
|
||||
@@ -0,0 +1,23 @@
|
||||
def main(prev: dict):
|
||||
print("=" * 60)
|
||||
print("TESTPAUSE — Flow stoppt hier. Ergebnisse:")
|
||||
print("=" * 60)
|
||||
|
||||
server_creds = prev.get("server_creds", {})
|
||||
needs_bitwarden = prev.get("needs_bitwarden", [])
|
||||
servers = prev.get("target_servers", [])
|
||||
|
||||
print(f"\nServer gesamt: {[s['hostname'] for s in servers]}")
|
||||
print(f"needs_bitwarden: {needs_bitwarden}")
|
||||
print(f"\nAuthentifizierung:")
|
||||
for hostname, creds in server_creds.items():
|
||||
method = creds.get("auth_method", "password")
|
||||
user = creds.get("username", "?")
|
||||
print(f" {hostname}: {method} ({user})")
|
||||
|
||||
for hostname in needs_bitwarden:
|
||||
if hostname not in server_creds:
|
||||
print(f" {hostname}: FEHLT — weder Key noch Bitwarden!")
|
||||
|
||||
print("=" * 60)
|
||||
return prev
|
||||
+23
-7
@@ -1,4 +1,21 @@
|
||||
import wmill, json, mysql.connector, paramiko, re, base64
|
||||
import wmill, json, mysql.connector, paramiko, io, re, base64
|
||||
|
||||
def _load_pkey(key_str: str):
|
||||
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
|
||||
try:
|
||||
return cls.from_private_key(io.StringIO(key_str))
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
def _ssh_connect(ip: str, user: str, password: str = "", private_key: str = "") -> paramiko.SSHClient:
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
if private_key:
|
||||
ssh.connect(ip, username=user, pkey=_load_pkey(private_key))
|
||||
else:
|
||||
ssh.connect(ip, username=user, password=password)
|
||||
return ssh
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
@@ -349,7 +366,7 @@ def main(from_init: dict):
|
||||
conn3 = mysql.connector.connect(**db_cfg)
|
||||
cur3 = conn3.cursor(dictionary=True)
|
||||
cur3.execute("""
|
||||
SELECT ip, ssh_user, ssh_password
|
||||
SELECT ip, ssh_user, ssh_password, ssh_private_key
|
||||
FROM Kunden.`bronze.restore.session`
|
||||
WHERE job_uuid = %s AND hostname = %s
|
||||
LIMIT 1
|
||||
@@ -366,12 +383,11 @@ def main(from_init: dict):
|
||||
webhook_url = wmill.get_variable("f/Backup/windmill_webhook_url")
|
||||
webhook_tok = wmill.get_variable("f/Backup/windmill_webhook_token")
|
||||
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(
|
||||
ssh = _ssh_connect(
|
||||
session["ip"],
|
||||
username=session["ssh_user"],
|
||||
password=session["ssh_password"]
|
||||
session["ssh_user"],
|
||||
session.get("ssh_password", "") or "",
|
||||
session.get("ssh_private_key", "") or "",
|
||||
)
|
||||
|
||||
safe_client = nxt["client_name"].replace("/", "_").replace(":", "_")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
summary: null
|
||||
display_name: mail_to_talk
|
||||
extra_perms:
|
||||
serfling@itdata-gera.de: true
|
||||
owners:
|
||||
- serfling@itdata-gera.de
|
||||
@@ -0,0 +1,8 @@
|
||||
description: IMAP Zugangsdaten fuer den Mail-zu-Talk Flow
|
||||
resource_type: imap
|
||||
value:
|
||||
host: mail.stines.de
|
||||
port: 993
|
||||
user: sebastianserfling@stines.de
|
||||
password: c6tzJBxDtNEU84ZAWY39eG8RUM5XR4UyfrDesfgCekEbFhkHDkbn
|
||||
mailbox: INBOX
|
||||
@@ -0,0 +1,8 @@
|
||||
description: IMAP Zugangsdaten fuer das zweite Postfach (itdata-gera.de)
|
||||
resource_type: imap
|
||||
value:
|
||||
host: mail.itdata-gera.de
|
||||
port: 993
|
||||
user: serfling@itdata-gera.de
|
||||
password: ''
|
||||
mailbox: INBOX
|
||||
@@ -0,0 +1,85 @@
|
||||
import imaplib
|
||||
import email
|
||||
from email.header import decode_header
|
||||
from typing import TypedDict, Optional, List
|
||||
from datetime import datetime
|
||||
import email.utils
|
||||
|
||||
|
||||
class imap(TypedDict):
|
||||
host: str
|
||||
port: int
|
||||
user: str
|
||||
password: str
|
||||
mailbox: Optional[str]
|
||||
|
||||
|
||||
def decode_str(value) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
parts = decode_header(value)
|
||||
result = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
result.append(part.decode(charset or "utf-8", errors="replace"))
|
||||
else:
|
||||
result.append(part)
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def main(imap_config: imap) -> List[dict]:
|
||||
host = imap_config["host"]
|
||||
port = imap_config["port"]
|
||||
user = imap_config["user"]
|
||||
password = imap_config["password"]
|
||||
mailbox = imap_config.get("mailbox") or "INBOX"
|
||||
|
||||
if port == 993:
|
||||
client = imaplib.IMAP4_SSL(host, port)
|
||||
else:
|
||||
client = imaplib.IMAP4(host, port)
|
||||
|
||||
client.login(user, password)
|
||||
client.select(mailbox, readonly=False)
|
||||
|
||||
# Nur ungelesene E-Mails suchen
|
||||
status, data = client.search(None, "UNSEEN")
|
||||
if status != "OK" or not data[0]:
|
||||
client.logout()
|
||||
return []
|
||||
|
||||
uids = data[0].split()
|
||||
emails = []
|
||||
|
||||
for uid in uids:
|
||||
status, msg_data = client.fetch(uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])")
|
||||
if status != "OK":
|
||||
continue
|
||||
|
||||
raw = msg_data[0][1]
|
||||
msg = email.message_from_bytes(raw)
|
||||
|
||||
subject = decode_str(msg.get("Subject", "(Kein Betreff)"))
|
||||
from_raw = msg.get("From", "Unbekannt")
|
||||
from_addr = decode_str(from_raw)
|
||||
|
||||
date_raw = msg.get("Date", "")
|
||||
try:
|
||||
parsed_date = email.utils.parsedate_to_datetime(date_raw)
|
||||
date_str = parsed_date.strftime("%d.%m.%Y %H:%M:%S")
|
||||
except Exception:
|
||||
date_str = date_raw or "Unbekanntes Datum"
|
||||
|
||||
# Als gelesen markieren
|
||||
client.store(uid, "+FLAGS", "\\Seen")
|
||||
|
||||
emails.append({
|
||||
"subject": subject,
|
||||
"from": from_addr,
|
||||
"date": date_str,
|
||||
"uid": int(uid),
|
||||
"account": user,
|
||||
})
|
||||
|
||||
client.logout()
|
||||
return emails
|
||||
@@ -0,0 +1,77 @@
|
||||
summary: E-Mails zu Nextcloud Talk
|
||||
description: >
|
||||
Liest ungelesene E-Mails von zwei IMAP-Konten und sendet Benachrichtigungen
|
||||
an einen Nextcloud Talk Raum. E-Mails werden als gelesen markiert.
|
||||
schema:
|
||||
$schema: "http://json-schema.org/draft-07/schema"
|
||||
type: object
|
||||
properties:
|
||||
imap_config_1:
|
||||
type: object
|
||||
format: resource-imap
|
||||
description: "IMAP Konto 1 (Ressource: f/mail_to_talk/imap_config)"
|
||||
imap_config_2:
|
||||
type: object
|
||||
format: resource-imap
|
||||
description: "IMAP Konto 2 (Ressource: f/mail_to_talk/imap_config_2)"
|
||||
nextcloud_config:
|
||||
type: object
|
||||
format: resource-nextcloud
|
||||
description: "Nextcloud Zugangsdaten (Ressource: f/mail_to_talk/nextcloud_talk_config)"
|
||||
required:
|
||||
- imap_config_1
|
||||
- imap_config_2
|
||||
- nextcloud_config
|
||||
value:
|
||||
modules:
|
||||
- id: fetch_emails_1
|
||||
summary: Ungelesene E-Mails Konto 1 abrufen
|
||||
value:
|
||||
type: rawscript
|
||||
language: python3
|
||||
content: "!inline fetch_emails.py"
|
||||
input_transforms:
|
||||
imap_config:
|
||||
type: javascript
|
||||
expr: flow_input.imap_config_1
|
||||
stop_after_if:
|
||||
expr: "false"
|
||||
skip_if_stopped: false
|
||||
|
||||
- id: fetch_emails_2
|
||||
summary: Ungelesene E-Mails Konto 2 abrufen
|
||||
value:
|
||||
type: rawscript
|
||||
language: python3
|
||||
content: "!inline fetch_emails.py"
|
||||
input_transforms:
|
||||
imap_config:
|
||||
type: javascript
|
||||
expr: flow_input.imap_config_2
|
||||
stop_after_if:
|
||||
expr: "false"
|
||||
skip_if_stopped: false
|
||||
|
||||
- id: send_to_talk
|
||||
summary: Jede E-Mail an Nextcloud Talk senden
|
||||
value:
|
||||
type: forloopflow
|
||||
iterator:
|
||||
type: javascript
|
||||
expr: "[...results.fetch_emails_1, ...results.fetch_emails_2]"
|
||||
skip_failures: false
|
||||
parallel: false
|
||||
modules:
|
||||
- id: send_message
|
||||
summary: Nachricht an Nextcloud Talk senden
|
||||
value:
|
||||
type: rawscript
|
||||
language: python3
|
||||
content: "!inline send_message.py"
|
||||
input_transforms:
|
||||
email:
|
||||
type: javascript
|
||||
expr: flow_input.iter.value
|
||||
nextcloud_config:
|
||||
type: javascript
|
||||
expr: flow_input.nextcloud_config
|
||||
@@ -0,0 +1,49 @@
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import json
|
||||
import base64
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
class nextcloud(TypedDict):
|
||||
baseUrl: str
|
||||
userId: str
|
||||
token: str
|
||||
|
||||
|
||||
TALK_TOKEN = "6bdts22w"
|
||||
|
||||
|
||||
def main(email: dict, nextcloud_config: nextcloud) -> dict:
|
||||
base_url = nextcloud_config["baseUrl"].rstrip("/")
|
||||
user_id = nextcloud_config["userId"]
|
||||
token = nextcloud_config["token"]
|
||||
|
||||
message = (
|
||||
f"Neue E-Mail an: {email.get('account', '')}\n"
|
||||
f"Von: {email['from']}\n"
|
||||
f"Betreff: {email['subject']}\n"
|
||||
f"Datum: {email['date']}"
|
||||
)
|
||||
|
||||
url = f"{base_url}/ocs/v2.php/apps/spreed/api/v1/chat/{TALK_TOKEN}"
|
||||
credentials = base64.b64encode(f"{user_id}:{token}".encode()).decode()
|
||||
|
||||
body = json.dumps({"message": message, "replyTo": 0}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={
|
||||
"OCS-APIRequest": "true",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": f"Basic {credentials}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
status = resp.status
|
||||
|
||||
return {"success": True, "status": status}
|
||||
@@ -0,0 +1,6 @@
|
||||
description: Nextcloud Zugangsdaten fuer den Mail-zu-Talk Flow
|
||||
resource_type: nextcloud
|
||||
value:
|
||||
baseUrl: https://cloudstorage.stines.de
|
||||
userId: reporting
|
||||
token: "5xw#HLH5kbMDbxNUUVA6iQcstytm4Ss4g9iGy7ZoLCTDTku6GPcXNHgRfSFgci9R"
|
||||
@@ -0,0 +1,38 @@
|
||||
description: IMAP mailbox credentials
|
||||
format_extension: null
|
||||
is_fileset: false
|
||||
schema:
|
||||
$schema: https://json-schema.org/draft/2020-12/schema
|
||||
type: object
|
||||
order:
|
||||
- host
|
||||
- port
|
||||
- user
|
||||
- password
|
||||
- mailbox
|
||||
properties:
|
||||
host:
|
||||
type: string
|
||||
description: IMAP server hostname
|
||||
default: ''
|
||||
port:
|
||||
type: integer
|
||||
description: 'IMAP port (993 = SSL, 143 = plain)'
|
||||
default: 993
|
||||
user:
|
||||
type: string
|
||||
description: IMAP username / e-mail address
|
||||
default: ''
|
||||
password:
|
||||
type: string
|
||||
description: IMAP password or app password
|
||||
default: ''
|
||||
mailbox:
|
||||
type: string
|
||||
description: Mailbox to monitor (default INBOX)
|
||||
default: INBOX
|
||||
required:
|
||||
- host
|
||||
- port
|
||||
- user
|
||||
- password
|
||||
Reference in New Issue
Block a user