feat: SSH-Key Auth mit Bitwarden-Fallback (Step I)
Neuer Step I prüft SSH-Keys aus DB vor Bitwarden-Abfrage. Key-Auth wird in Session gespeichert, Steps C/D/E nutzen Key wenn vorhanden. Bitwarden (Step G) wird nur noch für Server ohne gültigen Key aufgerufen. 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, paramiko.DSSKey]:
|
||||||
|
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):
|
def start_restore(server, backup, job_uuid, webhook_url, webhook_tok):
|
||||||
"""Startet restore.sh auf einem Server non-blocking."""
|
"""Startet restore.sh auf einem Server non-blocking."""
|
||||||
ssh = paramiko.SSHClient()
|
creds = server["ssh_creds"]
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
ssh = _ssh_connect(
|
||||||
ssh.connect(
|
|
||||||
server["ip"],
|
server["ip"],
|
||||||
username=server["ssh_creds"]["user"],
|
creds["user"],
|
||||||
password=server["ssh_creds"]["password"]
|
creds.get("password", ""),
|
||||||
|
creds.get("private_key", ""),
|
||||||
)
|
)
|
||||||
safe_client = backup["client_name"].replace("/", "_").replace(":", "_")
|
safe_client = backup["client_name"].replace("/", "_").replace(":", "_")
|
||||||
srv_hostname = server["hostname"]
|
srv_hostname = server["hostname"]
|
||||||
|
|||||||
@@ -45,8 +45,19 @@ value:
|
|||||||
expr: results.a
|
expr: results.a
|
||||||
lock: '!inline alle_freien_restore-server_holen.lock'
|
lock: '!inline alle_freien_restore-server_holen.lock'
|
||||||
language: python3
|
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
|
- id: g
|
||||||
summary: SSH-Credentials fuer alle Restore-Server aus Bitwarden
|
summary: SSH-Credentials fuer alle Restore-Server aus Bitwarden (Fallback)
|
||||||
value:
|
value:
|
||||||
type: rawscript
|
type: rawscript
|
||||||
content: '!inline ssh-credentials_fuer_alle_restore-server_aus_bitwarden.py'
|
content: '!inline ssh-credentials_fuer_alle_restore-server_aus_bitwarden.py'
|
||||||
@@ -56,7 +67,7 @@ value:
|
|||||||
value: https://bitwarden.stines.de
|
value: https://bitwarden.stines.de
|
||||||
prev:
|
prev:
|
||||||
type: javascript
|
type: javascript
|
||||||
expr: results.b
|
expr: results.i
|
||||||
lock: '!inline ssh-credentials_fuer_alle_restore-server_aus_bitwarden.lock'
|
lock: '!inline ssh-credentials_fuer_alle_restore-server_aus_bitwarden.lock'
|
||||||
language: python3
|
language: python3
|
||||||
- id: c
|
- id: c
|
||||||
|
|||||||
+32
-10
@@ -1,5 +1,23 @@
|
|||||||
import wmill, json, paramiko, io, mysql.connector, re
|
import wmill, json, paramiko, io, mysql.connector, re
|
||||||
|
|
||||||
|
def _load_pkey(key_str: str):
|
||||||
|
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.DSSKey]:
|
||||||
|
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"
|
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("""
|
cur.execute("""
|
||||||
INSERT INTO Kunden.`bronze.restore.session`
|
INSERT INTO Kunden.`bronze.restore.session`
|
||||||
(job_uuid, hostname, ip, ssh_user, ssh_password)
|
(job_uuid, hostname, ip, ssh_user, ssh_password, ssh_private_key)
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
ip=VALUES(ip), ssh_user=VALUES(ssh_user),
|
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,
|
job_uuid, hostname,
|
||||||
ssh_creds["ip"], ssh_creds["user"], ssh_creds["password"],
|
ssh_creds["ip"], ssh_creds["user"], ssh_creds["password"],
|
||||||
|
ssh_creds.get("private_key", "") or "",
|
||||||
))
|
))
|
||||||
|
|
||||||
conn.commit(); cur.close(); conn.close()
|
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.")
|
print(f"WARNUNG: Keine IP fuer '{hostname}' – uebersprungen.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
ssh_user = creds.get("username", "root")
|
||||||
|
ssh_password = creds.get("password", "")
|
||||||
|
ssh_private_key = creds.get("private_key", "")
|
||||||
|
|
||||||
ssh_creds_dict = {
|
ssh_creds_dict = {
|
||||||
"ip": ip,
|
"ip": ip,
|
||||||
"user": creds.get("username", "root"),
|
"user": ssh_user,
|
||||||
"password": creds.get("password", ""),
|
"password": ssh_password,
|
||||||
|
"private_key": ssh_private_key,
|
||||||
}
|
}
|
||||||
|
|
||||||
ssh = paramiko.SSHClient()
|
ssh = _ssh_connect(ip, ssh_user, ssh_password, ssh_private_key)
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
ssh.connect(ip, username=ssh_creds_dict["user"],
|
|
||||||
password=ssh_creds_dict["password"])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pbs_storages = deploy_to_server(
|
pbs_storages = deploy_to_server(
|
||||||
|
|||||||
+8
-2
@@ -34,7 +34,13 @@ def main(
|
|||||||
if prev.get("mode") == "webhook":
|
if prev.get("mode") == "webhook":
|
||||||
return prev
|
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"))
|
bw_creds = json.loads(wmill.get_variable("f/Backup/bitwarden_api_login"))
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
@@ -69,7 +75,7 @@ def main(
|
|||||||
raise Exception("Vault konnte nicht entsperrt werden")
|
raise Exception("Vault konnte nicht entsperrt werden")
|
||||||
env["BW_SESSION"] = bw_session
|
env["BW_SESSION"] = bw_session
|
||||||
|
|
||||||
server_creds = {}
|
server_creds = prev.get("server_creds", {})
|
||||||
for server in servers:
|
for server in servers:
|
||||||
hostname = server["hostname"]
|
hostname = server["hostname"]
|
||||||
print(f"Hole Creds fuer: {hostname}")
|
print(f"Hole Creds fuer: {hostname}")
|
||||||
|
|||||||
@@ -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,76 @@
|
|||||||
|
import wmill, mysql.connector, json, paramiko, io
|
||||||
|
|
||||||
|
def _load_pkey(key_str: str):
|
||||||
|
for cls in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.DSSKey]:
|
||||||
|
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))
|
||||||
|
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()}
|
||||||
|
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}
|
||||||
+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, paramiko.DSSKey]:
|
||||||
|
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
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -349,7 +366,7 @@ def main(from_init: dict):
|
|||||||
conn3 = mysql.connector.connect(**db_cfg)
|
conn3 = mysql.connector.connect(**db_cfg)
|
||||||
cur3 = conn3.cursor(dictionary=True)
|
cur3 = conn3.cursor(dictionary=True)
|
||||||
cur3.execute("""
|
cur3.execute("""
|
||||||
SELECT ip, ssh_user, ssh_password
|
SELECT ip, ssh_user, ssh_password, ssh_private_key
|
||||||
FROM Kunden.`bronze.restore.session`
|
FROM Kunden.`bronze.restore.session`
|
||||||
WHERE job_uuid = %s AND hostname = %s
|
WHERE job_uuid = %s AND hostname = %s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -366,12 +383,11 @@ def main(from_init: dict):
|
|||||||
webhook_url = wmill.get_variable("f/Backup/windmill_webhook_url")
|
webhook_url = wmill.get_variable("f/Backup/windmill_webhook_url")
|
||||||
webhook_tok = wmill.get_variable("f/Backup/windmill_webhook_token")
|
webhook_tok = wmill.get_variable("f/Backup/windmill_webhook_token")
|
||||||
|
|
||||||
ssh = paramiko.SSHClient()
|
ssh = _ssh_connect(
|
||||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
ssh.connect(
|
|
||||||
session["ip"],
|
session["ip"],
|
||||||
username=session["ssh_user"],
|
session["ssh_user"],
|
||||||
password=session["ssh_password"]
|
session.get("ssh_password", "") or "",
|
||||||
|
session.get("ssh_private_key", "") or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
safe_client = nxt["client_name"].replace("/", "_").replace(":", "_")
|
safe_client = nxt["client_name"].replace("/", "_").replace(":", "_")
|
||||||
|
|||||||
Reference in New Issue
Block a user