devconfig-gen-singbox 2.1.2__tar.gz → 2.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. {devconfig_gen_singbox-2.1.2/src/devconfig_gen_singbox.egg-info → devconfig_gen_singbox-2.1.3}/PKG-INFO +2 -2
  2. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/README.md +1 -1
  3. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/pyproject.toml +1 -1
  4. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/__init__.py +1 -1
  5. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3/src/devconfig_gen_singbox.egg-info}/PKG-INFO +2 -2
  6. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen_singbox.egg-info/SOURCES.txt +1 -0
  7. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/export/local_files.py +6 -0
  8. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/runtime/__init__.py +3 -0
  9. devconfig_gen_singbox-2.1.3/src/singbox_ops/adapters/runtime/auto_update.py +47 -0
  10. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/runtime/nftables.py +4 -0
  11. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/runtime/warp_watchdog.py +11 -4
  12. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/cli.py +1 -0
  13. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/core/orchestrator.py +7 -0
  14. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/core/plan.py +1 -0
  15. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_ops_runtime.py +29 -3
  16. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_ops_state_export.py +10 -0
  17. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/LICENSE +0 -0
  18. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/setup.cfg +0 -0
  19. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/cli.py +0 -0
  20. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/engine.py +0 -0
  21. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/formats.py +0 -0
  22. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/interactive.py +0 -0
  23. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/models.py +0 -0
  24. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/__init__.py +0 -0
  25. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/custom.py +0 -0
  26. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/env_provider.py +0 -0
  27. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/json_provider.py +0 -0
  28. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/__init__.py +0 -0
  29. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/data/rules.json +0 -0
  30. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/links.py +0 -0
  31. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/models.py +0 -0
  32. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/plugins/__init__.py +0 -0
  33. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/plugins/anytls.py +0 -0
  34. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/plugins/base.py +0 -0
  35. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/plugins/hysteria2.py +0 -0
  36. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/plugins/tuic.py +0 -0
  37. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/provider.py +0 -0
  38. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/route.py +0 -0
  39. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/providers/singbox/schema.py +0 -0
  40. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/py.typed +0 -0
  41. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/registry.py +0 -0
  42. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/validation.py +0 -0
  43. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen/web_ui.py +0 -0
  44. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen_singbox.egg-info/dependency_links.txt +0 -0
  45. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen_singbox.egg-info/entry_points.txt +0 -0
  46. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen_singbox.egg-info/requires.txt +0 -0
  47. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/devconfig_gen_singbox.egg-info/top_level.txt +0 -0
  48. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/__init__.py +0 -0
  49. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/__init__.py +0 -0
  50. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/acme/__init__.py +0 -0
  51. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/acme/base.py +0 -0
  52. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/acme/cloudflare_dns01.py +0 -0
  53. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/base.py +0 -0
  54. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/dns/__init__.py +0 -0
  55. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/dns/base.py +0 -0
  56. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/dns/cloudflare.py +0 -0
  57. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/export/__init__.py +0 -0
  58. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/runtime/packages.py +0 -0
  59. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/runtime/systemd.py +0 -0
  60. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/secrets/__init__.py +0 -0
  61. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/secrets/base.py +0 -0
  62. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/secrets/python_secrets.py +0 -0
  63. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/secrets/singbox_subprocess.py +0 -0
  64. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/secrets/static.py +0 -0
  65. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/state/__init__.py +0 -0
  66. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/adapters/state/local_json.py +0 -0
  67. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/core/__init__.py +0 -0
  68. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/core/command.py +0 -0
  69. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/core/context_builder.py +0 -0
  70. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/core/exceptions.py +0 -0
  71. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/core/network.py +0 -0
  72. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/core/protocols.py +0 -0
  73. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/templates/singbox_auto_update.py.template +0 -0
  74. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/src/singbox_ops/templates/warp_watchdog.sh.template +0 -0
  75. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_api_parity.py +0 -0
  76. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_cli_e2e.py +0 -0
  77. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_custom_provider.py +0 -0
  78. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_devconfig_core.py +0 -0
  79. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_env_provider.py +0 -0
  80. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_formats.py +0 -0
  81. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_interactive.py +0 -0
  82. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_merge.py +0 -0
  83. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_provider_metadata.py +0 -0
  84. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_ops_acme.py +0 -0
  85. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_ops_cli.py +0 -0
  86. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_ops_context_builder.py +0 -0
  87. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_ops_dns.py +0 -0
  88. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_ops_orchestrator.py +0 -0
  89. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_ops_plan.py +0 -0
  90. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_ops_secrets.py +0 -0
  91. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_singbox_provider.py +0 -0
  92. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_validation.py +0 -0
  93. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_web_ui.py +0 -0
  94. {devconfig_gen_singbox-2.1.2 → devconfig_gen_singbox-2.1.3}/tests/test_web_ui_widgets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devconfig_gen_singbox
3
- Version: 2.1.2
3
+ Version: 2.1.3
4
4
  Summary: Provider-based developer configuration generation engine and CLI
5
5
  Author-email: henryliu443 <henryliu443@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -28,7 +28,7 @@ Dynamic: license-file
28
28
  > **A sing-box domain implementation built on top of DevConfig-Gen.**
29
29
  > **构建在 `DevConfig-Gen` 之上的 sing-box 领域实现(child / fork)。**
30
30
 
31
- [![Version](https://img.shields.io/badge/version-2.1.2-blue.svg)](CHANGELOG.md)
31
+ [![Version](https://img.shields.io/badge/version-2.1.3-blue.svg)](CHANGELOG.md)
32
32
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
33
33
  ![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue.svg)
34
34
  ![Tests](https://img.shields.io/badge/tests-136%20passing-brightgreen.svg)
@@ -3,7 +3,7 @@
3
3
  > **A sing-box domain implementation built on top of DevConfig-Gen.**
4
4
  > **构建在 `DevConfig-Gen` 之上的 sing-box 领域实现(child / fork)。**
5
5
 
6
- [![Version](https://img.shields.io/badge/version-2.1.2-blue.svg)](CHANGELOG.md)
6
+ [![Version](https://img.shields.io/badge/version-2.1.3-blue.svg)](CHANGELOG.md)
7
7
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
8
8
  ![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue.svg)
9
9
  ![Tests](https://img.shields.io/badge/tests-136%20passing-brightgreen.svg)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devconfig_gen_singbox"
7
- version = "2.1.2"
7
+ version = "2.1.3"
8
8
  authors = [
9
9
  { name = "henryliu443", email = "henryliu443@gmail.com" },
10
10
  ]
@@ -66,4 +66,4 @@ __all__ = [
66
66
  "loads",
67
67
  "validate_request",
68
68
  ]
69
- __version__ = "2.1.2"
69
+ __version__ = "2.1.3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devconfig_gen_singbox
3
- Version: 2.1.2
3
+ Version: 2.1.3
4
4
  Summary: Provider-based developer configuration generation engine and CLI
5
5
  Author-email: henryliu443 <henryliu443@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -28,7 +28,7 @@ Dynamic: license-file
28
28
  > **A sing-box domain implementation built on top of DevConfig-Gen.**
29
29
  > **构建在 `DevConfig-Gen` 之上的 sing-box 领域实现(child / fork)。**
30
30
 
31
- [![Version](https://img.shields.io/badge/version-2.1.2-blue.svg)](CHANGELOG.md)
31
+ [![Version](https://img.shields.io/badge/version-2.1.3-blue.svg)](CHANGELOG.md)
32
32
  [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
33
33
  ![Python 3.8+](https://img.shields.io/badge/python-3.8%2B-blue.svg)
34
34
  ![Tests](https://img.shields.io/badge/tests-136%20passing-brightgreen.svg)
@@ -46,6 +46,7 @@ src/singbox_ops/adapters/dns/cloudflare.py
46
46
  src/singbox_ops/adapters/export/__init__.py
47
47
  src/singbox_ops/adapters/export/local_files.py
48
48
  src/singbox_ops/adapters/runtime/__init__.py
49
+ src/singbox_ops/adapters/runtime/auto_update.py
49
50
  src/singbox_ops/adapters/runtime/nftables.py
50
51
  src/singbox_ops/adapters/runtime/packages.py
51
52
  src/singbox_ops/adapters/runtime/systemd.py
@@ -56,6 +56,12 @@ class LocalFilesExport:
56
56
  destination = outputs.get(key)
57
57
  if not destination:
58
58
  continue
59
+ # Single-file model: never write *through* a legacy symlink (for
60
+ # example config.json -> profiles/config.direct.json). Replace the
61
+ # link with a real file so the old profile-switching scheme cannot
62
+ # silently redirect our write.
63
+ if not dry_run and self.runner.islink(destination):
64
+ self.runner.unlink(destination)
59
65
  text = serialize_artifact(artifact)
60
66
  self.runner.write_text(destination, text, mode=0o600)
61
67
  written[key] = destination
@@ -2,16 +2,19 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from .auto_update import AutoUpdateRuntime, build_auto_update_script
5
6
  from .nftables import NftablesFirewall, build_nftables_conf, detect_ssh_port
6
7
  from .packages import DebianPackagesRuntime
7
8
  from .systemd import SystemdRuntime
8
9
  from .warp_watchdog import WarpWatchdogRuntime
9
10
 
10
11
  __all__ = [
12
+ "AutoUpdateRuntime",
11
13
  "DebianPackagesRuntime",
12
14
  "NftablesFirewall",
13
15
  "SystemdRuntime",
14
16
  "WarpWatchdogRuntime",
17
+ "build_auto_update_script",
15
18
  "build_nftables_conf",
16
19
  "detect_ssh_port",
17
20
  ]
@@ -0,0 +1,47 @@
1
+ """Daily sing-box auto-update job (copied verbatim from the legacy deployment).
2
+
3
+ The script is shipped as a template and installed as-is, then scheduled through
4
+ ``/etc/cron.d`` so no interactive crontab editing is needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Mapping, Optional
11
+
12
+ from ...core.command import CommandRunner, LocalCommandRunner
13
+
14
+ SCRIPT_PATH = "/usr/local/bin/singbox_auto_update.py"
15
+ CRON_PATH = "/etc/cron.d/singbox-auto-update"
16
+ LOG_PATH = "/var/log/singbox-auto-update.log"
17
+ CRON_SCHEDULE = "17 4 * * *"
18
+
19
+ _TEMPLATE = Path(__file__).resolve().parents[2] / "templates" / "singbox_auto_update.py.template"
20
+
21
+
22
+ def build_auto_update_script() -> str:
23
+ return _TEMPLATE.read_text(encoding="utf-8")
24
+
25
+
26
+ def build_cron_entry(script_path: str = SCRIPT_PATH, log_path: str = LOG_PATH) -> str:
27
+ return f"{CRON_SCHEDULE} root /usr/bin/env python3 {script_path} >> {log_path} 2>&1\n"
28
+
29
+
30
+ class AutoUpdateRuntime:
31
+ def __init__(
32
+ self,
33
+ runner: Optional[CommandRunner] = None,
34
+ script_path: str = SCRIPT_PATH,
35
+ cron_path: str = CRON_PATH,
36
+ ):
37
+ self.runner = runner or LocalCommandRunner()
38
+ self.script_path = script_path
39
+ self.cron_path = cron_path
40
+
41
+ def apply(self, plan, hosts: Mapping[str, str], *, dry_run: bool = False) -> None:
42
+ self.runner.write_text(self.script_path, build_auto_update_script(), mode=0o755)
43
+ self.runner.write_text(self.cron_path, build_cron_entry(self.script_path), mode=0o644)
44
+
45
+ def destroy(self, plan, hosts: Mapping[str, str], *, dry_run: bool = False) -> None:
46
+ self.runner.unlink(self.cron_path, missing_ok=True)
47
+ self.runner.unlink(self.script_path, missing_ok=True)
@@ -90,6 +90,10 @@ class NftablesFirewall:
90
90
  ports = protocol_ports(plan.protocols)
91
91
  ssh_port = detect_ssh_port(self.runner)
92
92
  conf = build_nftables_conf(ports["tcp"], ports["udp"], ssh_port)
93
+ # Takeover: drop any previous table (legacy included) before applying
94
+ # ours, so chains never stack up and ``destroy`` cannot half-remove a
95
+ # foreign ruleset.
96
+ self.runner.run(["nft", "delete", "table", "inet", "singbox_guard"], check=False)
93
97
  self.runner.mkdir("/etc/nftables.d", mode=0o755)
94
98
  self.runner.write_text(self.conf_path, conf, mode=0o644)
95
99
  self.runner.run(["nft", "-f", self.conf_path], check=True)
@@ -62,11 +62,18 @@ class WarpWatchdogRuntime:
62
62
  self.cron_path = cron_path
63
63
 
64
64
  def apply(self, plan, hosts: Mapping[str, str], *, dry_run: bool = False) -> None:
65
- if plan.tunnel_mode not in ("proxy", "tun"):
65
+ if plan.tunnel_mode in ("proxy", "tun"):
66
+ script = build_watchdog_script(plan.tunnel_mode)
67
+ self.runner.write_text(self.script_path, script, mode=0o755)
68
+ self.runner.write_text(self.cron_path, build_cron_entry(self.script_path), mode=0o644)
66
69
  return
67
- script = build_watchdog_script(plan.tunnel_mode)
68
- self.runner.write_text(self.script_path, script, mode=0o755)
69
- self.runner.write_text(self.cron_path, build_cron_entry(self.script_path), mode=0o644)
70
+
71
+ # ``direct`` / ``none``: the server does not use WARP, so cancel the
72
+ # watchdog and stop the WARP daemon instead of babysitting it forever.
73
+ self.runner.unlink(self.cron_path, missing_ok=True)
74
+ self.runner.unlink(self.script_path, missing_ok=True)
75
+ self.runner.run(["pkill", "-9", "-x", "warp-svc"], check=False)
76
+ self.runner.run(["systemctl", "disable", "warp-svc"], check=False)
70
77
 
71
78
  def destroy(self, plan, hosts: Mapping[str, str], *, dry_run: bool = False) -> None:
72
79
  self.runner.unlink(self.cron_path, missing_ok=True)
@@ -120,6 +120,7 @@ def interactive_plan(domain=None, protocols=None, reader=None, writer=None) -> d
120
120
  "systemd": "systemd" if use_runtime else None,
121
121
  "firewall": "nftables-basic" if use_runtime else None,
122
122
  "watchdog": "warp" if use_runtime else None,
123
+ "auto_update": "auto-update" if use_runtime else None,
123
124
  },
124
125
  },
125
126
  "outputs": {
@@ -15,6 +15,7 @@ from devconfig_gen import formats
15
15
  from ..adapters.acme.cloudflare_dns01 import CloudflareDNS01ACME
16
16
  from ..adapters.dns.cloudflare import CloudflareDNS
17
17
  from ..adapters.export.local_files import LocalFilesExport
18
+ from ..adapters.runtime.auto_update import AutoUpdateRuntime
18
19
  from ..adapters.runtime.nftables import NftablesFirewall
19
20
  from ..adapters.runtime.packages import DebianPackagesRuntime
20
21
  from ..adapters.runtime.systemd import SystemdRuntime
@@ -44,6 +45,7 @@ RUNTIME_FACTORIES = {
44
45
  "systemd": SystemdRuntime,
45
46
  "nftables-basic": NftablesFirewall,
46
47
  "warp": WarpWatchdogRuntime,
48
+ "auto-update": AutoUpdateRuntime,
47
49
  }
48
50
  STATE_FACTORIES = {"local-json": LocalJsonState}
49
51
 
@@ -59,6 +61,7 @@ class AdapterSuite:
59
61
  systemd: Optional[Any] = None
60
62
  firewall: Optional[Any] = None
61
63
  watchdog: Optional[Any] = None
64
+ auto_update: Optional[Any] = None
62
65
 
63
66
 
64
67
  @dataclass
@@ -126,6 +129,7 @@ def build_suite(plan: DeployPlan, runner: CommandRunner) -> AdapterSuite:
126
129
  systemd = _runtime(plan, "systemd", runtime_kwargs)
127
130
  firewall = _runtime(plan, "firewall", runtime_kwargs)
128
131
  watchdog = _runtime(plan, "watchdog", runtime_kwargs)
132
+ auto_update = _runtime(plan, "auto_update", runtime_kwargs)
129
133
 
130
134
  return AdapterSuite(
131
135
  secrets=secrets,
@@ -137,6 +141,7 @@ def build_suite(plan: DeployPlan, runner: CommandRunner) -> AdapterSuite:
137
141
  systemd=systemd,
138
142
  firewall=firewall,
139
143
  watchdog=watchdog,
144
+ auto_update=auto_update,
140
145
  )
141
146
 
142
147
 
@@ -204,6 +209,7 @@ def deploy(
204
209
  ("systemd", suite.systemd),
205
210
  ("firewall", suite.firewall),
206
211
  ("watchdog", suite.watchdog),
212
+ ("auto_update", suite.auto_update),
207
213
  ):
208
214
  if adapter is None:
209
215
  continue
@@ -257,6 +263,7 @@ def destroy(
257
263
 
258
264
  for key, adapter in (
259
265
  ("watchdog", suite.watchdog),
266
+ ("auto_update", suite.auto_update),
260
267
  ("firewall", suite.firewall),
261
268
  ("systemd", suite.systemd),
262
269
  ("packages", suite.packages),
@@ -40,6 +40,7 @@ DEFAULT_ADAPTERS: Mapping[str, Any] = {
40
40
  "systemd": "systemd",
41
41
  "firewall": "nftables-basic",
42
42
  "watchdog": "warp",
43
+ "auto_update": "auto-update",
43
44
  },
44
45
  }
45
46
 
@@ -1,5 +1,6 @@
1
1
  import unittest
2
2
 
3
+ from singbox_ops.adapters.runtime.auto_update import AutoUpdateRuntime
3
4
  from singbox_ops.adapters.runtime.nftables import (
4
5
  NftablesFirewall,
5
6
  build_nftables_conf,
@@ -44,10 +45,12 @@ class NftablesTests(unittest.TestCase):
44
45
  def test_detect_ssh_port_default(self):
45
46
  self.assertEqual(detect_ssh_port(FakeRunner()), 22)
46
47
 
47
- def test_apply_writes_and_loads(self):
48
+ def test_apply_deletes_legacy_table_then_loads(self):
48
49
  runner = FakeRunner()
49
50
  NftablesFirewall(runner=runner).apply(plan_for(), {})
50
- self.assertTrue(any(cmd[0] == "run" and cmd[1][0] == "nft" for cmd in runner.commands))
51
+ runs = [cmd[1] for cmd in runner.commands if cmd[0] == "run"]
52
+ self.assertIn(("nft", "delete", "table", "inet", "singbox_guard"), runs)
53
+ self.assertTrue(any(item[0] == "nft" and item[1] == "-f" for item in runs))
51
54
  self.assertTrue(runner.writes)
52
55
 
53
56
 
@@ -80,10 +83,33 @@ class WatchdogTests(unittest.TestCase):
80
83
  self.assertIn("/root/warp_lazy_watchdog.sh", paths)
81
84
  self.assertIn("/etc/cron.d/singbox-warp-watchdog", paths)
82
85
 
83
- def test_skips_for_none_mode(self):
86
+ def test_direct_mode_cancels_watchdog_and_kills_warp(self):
84
87
  runner = FakeRunner()
85
88
  WarpWatchdogRuntime(runner=runner).apply(plan_for("none"), {})
86
89
  self.assertEqual(runner.writes, [])
90
+ self.assertIn("/etc/cron.d/singbox-warp-watchdog", runner.removed)
91
+ self.assertIn("/root/warp_lazy_watchdog.sh", runner.removed)
92
+ runs = [cmd[1] for cmd in runner.commands if cmd[0] == "run"]
93
+ self.assertIn(("pkill", "-9", "-x", "warp-svc"), runs)
94
+
95
+
96
+ class AutoUpdateTests(unittest.TestCase):
97
+ def test_apply_writes_script_and_cron(self):
98
+ runner = FakeRunner()
99
+ AutoUpdateRuntime(runner=runner).apply(plan_for("none"), {})
100
+ paths = [path for path, _, _ in runner.writes]
101
+ self.assertIn("/usr/local/bin/singbox_auto_update.py", paths)
102
+ self.assertIn("/etc/cron.d/singbox-auto-update", paths)
103
+ cron = next(text for path, text, _ in runner.writes if path.endswith("singbox-auto-update"))
104
+ self.assertIn("17 4 * * *", cron)
105
+ script = next(text for path, text, _ in runner.writes if path.endswith("singbox_auto_update.py"))
106
+ self.assertIn("SagerNet/sing-box", script)
107
+
108
+ def test_destroy_removes_both(self):
109
+ runner = FakeRunner()
110
+ AutoUpdateRuntime(runner=runner).destroy(plan_for("none"), {})
111
+ self.assertIn("/etc/cron.d/singbox-auto-update", runner.removed)
112
+ self.assertIn("/usr/local/bin/singbox_auto_update.py", runner.removed)
87
113
 
88
114
 
89
115
  class PackagesTests(unittest.TestCase):
@@ -42,6 +42,16 @@ class ExportTests(unittest.TestCase):
42
42
  self.assertEqual(match_output_key("sing-box-links.txt"), "links")
43
43
  self.assertIsNone(match_output_key("unknown.txt"))
44
44
 
45
+ def test_replaces_symlink_destination(self):
46
+ runner = FakeRunner(links=["/etc/sing-box/config.json"])
47
+ artifacts = [
48
+ GeneratedArtifact("sing-box.server.json", {"a": 1}, "application/json"),
49
+ ]
50
+ LocalFilesExport(runner=runner).write(artifacts, {"server_config": "/etc/sing-box/config.json"})
51
+ # the legacy symlink is removed, then a real file is written
52
+ self.assertIn("/etc/sing-box/config.json", runner.removed)
53
+ self.assertTrue(any(path == "/etc/sing-box/config.json" for path, _, _ in runner.writes))
54
+
45
55
  def test_writes_artifacts(self):
46
56
  runner = FakeRunner()
47
57
  artifacts = [