Files
gitea-admin 6f933e1c80 Add Herdr update and live handoff menu actions
The Ctrl+A h command menu gains two host-scoped actions: a plain
`herdr update` that streams updater output into the popup, and an
experimental update-and-hot-restart flow that verifies a live server
handoff and reconnects the originating Hub tab once the popup closes.

Remote updates use the remote host's own proxy settings. A standalone
helper reads literal proxy assignments from shell profiles and running
Clash/Mihomo listeners without executing startup files; Windows reads
process, user and machine proxy variables through PowerShell.
2026-10-03 02:51:44 +00:00

127 lines
6.4 KiB
Python

from __future__ import annotations
import base64
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
from herdr_hub.cli import Endpoint
from herdr_hub.control import HerdrControl
from herdr_hub.proxy_env import configure_application_proxy, configure_proxy, proxy_assignments, running_proxy_paths
class RemoteProxyTests(unittest.TestCase):
def test_running_mihomo_custom_config_is_discovered(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
core = root / "1277"
core.mkdir()
(core / "cmdline").write_bytes(
b"/opt/mihomo-linux-arm64\0-d\0/home/orangepi/repo/clash\0-f\0/home/orangepi/repo/clash/config-systemd.yaml\0"
)
unrelated = root / "1278"
unrelated.mkdir()
(unrelated / "cmdline").write_bytes(b"python3\0-c\0mihomo\0-f\0/irrelevant.yaml\0")
self.assertEqual(running_proxy_paths(root), [Path("/home/orangepi/repo/clash/config-systemd.yaml")])
def test_running_clash_listener_used_when_shell_has_no_proxy(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "clash-verge.yaml"
path.write_text("mixed-port: 7897\nbind-address: '*'\nallow-lan: false\n")
environment = {"no_proxy": "localhost,internal"}
with patch("herdr_hub.proxy_env.socket.create_connection") as connect:
source = configure_application_proxy(environment, [path])
self.assertEqual(source, str(path))
self.assertEqual(environment["https_proxy"], "http://127.0.0.1:7897")
self.assertEqual(environment["no_proxy"], "localhost,internal")
connect.assert_called_once_with(("127.0.0.1", 7897), timeout=1)
def test_stopped_application_and_authenticated_proxy_not_adopted(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "config.yaml"
for contents in ("mixed-port: 7897\n", "mixed-port: 7897\nauthentication:\n - user:secret\n"):
path.write_text(contents)
environment = {}
with patch("herdr_hub.proxy_env.socket.create_connection", side_effect=ConnectionRefusedError):
self.assertIsNone(configure_application_proxy(environment, [path]))
self.assertEqual(environment, {})
def test_application_detection_respects_existing_proxy_and_disable(self):
for value in ("", "http://configured:8080"):
environment = {"HTTPS_PROXY": value}
with patch("herdr_hub.proxy_env.socket.create_connection") as connect:
self.assertIsNone(configure_application_proxy(environment, [Path("/unused")]))
connect.assert_not_called()
self.assertEqual(environment, {"HTTPS_PROXY": value})
def test_literal_exports_and_uppercase_are_read_without_shell_evaluation(self):
values = proxy_assignments('''
export https_proxy="http://remote-user:secret@127.0.0.1:7890" # local to remote
HTTP_PROXY=http://remote-host:8080
export no_proxy='localhost,127.0.0.1'
export all_proxy=$(dangerous_command)
export all_proxy=http://other:8080; dangerous_command
export all_proxy=http://inside-function:8080
''')
self.assertEqual(values, {
"https_proxy": "http://remote-user:secret@127.0.0.1:7890",
"http_proxy": "http://remote-host:8080",
"no_proxy": "localhost,127.0.0.1",
})
def test_remote_environment_wins_including_explicit_disable(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".bashrc"
path.write_text("export https_proxy=http://file:7890\nexport all_proxy=http://fallback:7890\n")
environment = {"HTTPS_PROXY": "http://remote-env:7890", "all_proxy": ""}
configure_proxy(environment, [path])
self.assertEqual(environment["https_proxy"], "http://remote-env:7890")
self.assertEqual(environment["all_proxy"], "")
def test_wrapper_loads_remote_bashrc_and_never_embeds_local_proxy(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
marker = root / "should-not-exist"
remote_proxy = "http://remote-user:remote-secret@127.0.0.1:7891"
(root / ".bashrc").write_text(
"case $- in *i*) ;; *) return;; esac\n"
+ f"export https_proxy={shlex.quote(remote_proxy)}\n"
+ f"touch {shlex.quote(str(marker))}\n"
)
controller = HerdrControl(Endpoint.remote("host"), "unused")
controller.platform = "posix"
child = shlex.join([sys.executable, "-c", "import os; assert os.environ['https_proxy'] == " + repr(remote_proxy)])
with patch.dict(os.environ, {"https_proxy": "http://local-secret@127.0.0.1:9999"}):
command = controller._update_proxy(["ssh", "host", child], None)
self.assertEqual(command[:-1], ["ssh", "host"])
self.assertNotIn("local-secret", command[-1])
environment = {k: v for k, v in os.environ.items() if k.lower() not in {
"http_proxy", "https_proxy", "all_proxy", "no_proxy",
}}
environment.update(HOME=directory, SHELL="/bin/bash")
result = subprocess.run(["sh", "-c", command[-1]], env=environment, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("使用远端自身代理", result.stdout)
self.assertNotIn("remote-secret", result.stdout + result.stderr)
self.assertFalse(marker.exists())
def test_windows_loads_only_remote_environment_scopes(self):
controller = HerdrControl(Endpoint.remote("windows-host"), "unused")
controller.platform = "windows"
with patch.dict(os.environ, {"https_proxy": "http://local-secret@127.0.0.1:9999"}):
command = controller._update_proxy(["ssh", "windows-host", "unused"], None)
script = base64.b64decode(command[-1].split()[-1]).decode("utf-16le")
for scope in ("Process", "User", "Machine"):
self.assertIn(f"GetEnvironmentVariable($key,'{scope}')", script)
self.assertNotIn("local-secret", script)
self.assertNotIn("-R", command)
if __name__ == "__main__":
unittest.main()