Wire up a real main<->VM SSH path for the standby's remote-info feature

The standby's "SSH to main server for containers/stats/system info"
fallback (RUNNING_ON_MAIN_SERVER=False path) was wired in code across
backups.py/users.py/commands.py/app.py but never actually worked:
outbound SSH from the VM is blocked by its own firewall (deliberate
hardening — ufw DENY OUT on 22/tcp and 2222/tcp, left untouched), and
separately the VM's contabo-key was never added to the main server's
authorized_keys in the first place.

Fixed via a persistent reverse tunnel instead of opening the VM's
firewall: main-to-vm-tunnel.service (systemd, auto-restart) runs on the
main server and keeps `ssh -R 2224:localhost:22` open to the VM, so the
VM can reach the main server's SSH via localhost:2224 without ever
needing outbound access itself. Added the standby's public key to the
main server's authorized_keys (chattr +i-locked — unlocked, appended,
re-locked immediately).

config.py (gitignored, not in this commit — edited live on both servers
directly) gets new MAIN_SERVER_SSH_HOST/PORT ("localhost"/"2224"), kept
deliberately separate from the existing MAIN_SERVER_IP (which stays the
real IP and is still used for on-page display in several templates —
overloading it for the tunnel target would have silently changed what's
shown in the UI). All four call sites switched from
MAIN_SERVER_IP/MAIN_SERVER_PORT to the new pair. Also fixed a pre-existing
bug in commands.py's run_command(): its SSH fallback had no -i key at all,
so it could never have authenticated even with the tunnel in place.

Verified end-to-end on the live standby: run_command(), backups.py's and
users.py's _ssh_main() all confirmed to actually execute on the real main
server (hostname/whoami/backup count all matched), not erroring or
silently no-op'ing.
This commit is contained in:
root
2026-08-21 13:26:07 +02:00
parent 5329799a5a
commit 4dfd512785
4 changed files with 20 additions and 15 deletions

View File

@@ -11,7 +11,8 @@ from datetime import datetime, timezone
from config import ( from config import (
MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER, MAIN_SERVER_IP, RUNNING_ON_MAIN_SERVER,
VM_HOST, VM_PORT, VM_KEY, VM_USER, VM_HOST, VM_PORT, VM_KEY, VM_USER,
MAIN_SERVER_KEY, MAIN_SERVER_PORT, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_USER,
MAIN_SERVER_SSH_HOST, MAIN_SERVER_SSH_PORT,
) )
from modules.auth import login_required from modules.auth import login_required
from modules.backups import ( from modules.backups import (
@@ -695,9 +696,9 @@ def api_r2_upload():
local_path = f"/tmp/{backup_file}" local_path = f"/tmp/{backup_file}"
if not os.path.exists(local_path): if not os.path.exists(local_path):
pull_cmd = ( pull_cmd = (
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_PORT} " f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_SSH_PORT} "
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 " f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} " f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}:/root/backups/{backup_file} "
f"{local_path}" f"{local_path}"
) )
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True) res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)
@@ -799,9 +800,9 @@ def restore_start():
backup_path = f"/tmp/{backup_file}" backup_path = f"/tmp/{backup_file}"
if not os.path.exists(backup_path): if not os.path.exists(backup_path):
pull_cmd = ( pull_cmd = (
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_PORT} " f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_SSH_PORT} "
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 " f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} " f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}:/root/backups/{backup_file} "
f"{backup_path}" f"{backup_path}"
) )
res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True) res = subprocess.run(pull_cmd, shell=True, capture_output=True, text=True)

View File

@@ -11,7 +11,8 @@ import socket
import psutil import psutil
from config import ( from config import (
RUNNING_ON_MAIN_SERVER, RUNNING_ON_MAIN_SERVER,
MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT, MAIN_SERVER_USER, MAIN_SERVER_KEY,
MAIN_SERVER_SSH_HOST, MAIN_SERVER_SSH_PORT,
VM_HOST, VM_PORT, VM_KEY, VM_USER, VM_HOST, VM_PORT, VM_KEY, VM_USER,
) )
@@ -43,10 +44,10 @@ def _ssh_main(remote_cmd, timeout=30):
else: else:
escaped = remote_cmd.replace("'", "'\\''") escaped = remote_cmd.replace("'", "'\\''")
ssh = ( ssh = (
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_PORT} " f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_SSH_PORT} "
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 " f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
f"-o BatchMode=yes " f"-o BatchMode=yes "
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}" f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}"
) )
return _run(f"{ssh} '{escaped}'", timeout=timeout) return _run(f"{ssh} '{escaped}'", timeout=timeout)
@@ -275,9 +276,9 @@ def _resolve_archive_path(backup_file, source, add):
tmp_path = f"/tmp/audit_{backup_file}" tmp_path = f"/tmp/audit_{backup_file}"
if not os.path.exists(tmp_path): if not os.path.exists(tmp_path):
pull_cmd = ( pull_cmd = (
f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_PORT} " f"scp -i {MAIN_SERVER_KEY} -P {MAIN_SERVER_SSH_PORT} "
f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 " f"-o StrictHostKeyChecking=no -o ConnectTimeout=15 "
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}:/root/backups/{backup_file} " f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}:/root/backups/{backup_file} "
f"{tmp_path}" f"{tmp_path}"
) )
_out, err = _run(pull_cmd, timeout=120) _out, err = _run(pull_cmd, timeout=120)

View File

@@ -1,5 +1,5 @@
import subprocess import subprocess
from config import RUNNING_ON_MAIN_SERVER, MAIN_SERVER_IP from config import RUNNING_ON_MAIN_SERVER, MAIN_SERVER_KEY, MAIN_SERVER_SSH_HOST, MAIN_SERVER_SSH_PORT
def run_command(command): def run_command(command):
"""Run command on main server (directly or via SSH)""" """Run command on main server (directly or via SSH)"""
@@ -14,10 +14,12 @@ def run_command(command):
else: else:
ssh_cmd = [ ssh_cmd = [
"ssh", "ssh",
"-i", MAIN_SERVER_KEY,
"-p", MAIN_SERVER_SSH_PORT,
"-o", "StrictHostKeyChecking=no", "-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=10", "-o", "ConnectTimeout=10",
"-o", "BatchMode=yes", "-o", "BatchMode=yes",
f"root@{MAIN_SERVER_IP}", f"root@{MAIN_SERVER_SSH_HOST}",
command command
] ]
try: try:

View File

@@ -7,7 +7,8 @@ import json
from config import ( from config import (
RUNNING_ON_MAIN_SERVER, RUNNING_ON_MAIN_SERVER,
MAIN_SERVER_IP, MAIN_SERVER_USER, MAIN_SERVER_KEY, MAIN_SERVER_PORT, MAIN_SERVER_USER, MAIN_SERVER_KEY,
MAIN_SERVER_SSH_HOST, MAIN_SERVER_SSH_PORT,
) )
@@ -33,10 +34,10 @@ def _ssh_main(remote_cmd, timeout=30):
# Escape single quotes in remote_cmd for safe shell wrapping # Escape single quotes in remote_cmd for safe shell wrapping
escaped = remote_cmd.replace("'", "'\\''") escaped = remote_cmd.replace("'", "'\\''")
ssh = ( ssh = (
f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_PORT} " f"ssh -i {MAIN_SERVER_KEY} -p {MAIN_SERVER_SSH_PORT} "
f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 " f"-o StrictHostKeyChecking=no -o ConnectTimeout=10 "
f"-o BatchMode=yes " f"-o BatchMode=yes "
f"{MAIN_SERVER_USER}@{MAIN_SERVER_IP}" f"{MAIN_SERVER_USER}@{MAIN_SERVER_SSH_HOST}"
) )
return _run(f"{ssh} '{escaped}'", timeout=timeout) return _run(f"{ssh} '{escaped}'", timeout=timeout)