sing-box-cli 0.1.0__tar.gz → 0.1.2__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.
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/PKG-INFO +59 -9
- sing_box_cli-0.1.2/README.md +100 -0
- sing_box_cli-0.1.2/hatch_build.py +47 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/pyproject.toml +8 -1
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/__init__.py +1 -1
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/config/config.py +12 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/main.py +2 -2
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/service/manager.py +54 -110
- sing_box_cli-0.1.2/windows-service/common.go +121 -0
- sing_box_cli-0.1.2/windows-service/common_test.go +66 -0
- sing_box_cli-0.1.2/windows-service/go.mod +5 -0
- sing_box_cli-0.1.2/windows-service/go.sum +2 -0
- sing_box_cli-0.1.2/windows-service/integration_windows_test.go +141 -0
- sing_box_cli-0.1.2/windows-service/main_other.go +13 -0
- sing_box_cli-0.1.2/windows-service/main_windows.go +385 -0
- sing_box_cli-0.1.2/windows-service/testdata/core.go +30 -0
- sing_box_cli-0.1.0/.github/dependabot.yml +0 -16
- sing_box_cli-0.1.0/.github/workflows/main.yml +0 -99
- sing_box_cli-0.1.0/.pre-commit-config.yaml +0 -50
- sing_box_cli-0.1.0/CHANGELOG.md +0 -545
- sing_box_cli-0.1.0/README.md +0 -50
- sing_box_cli-0.1.0/assets/image.png +0 -0
- sing_box_cli-0.1.0/scripts/bump.sh +0 -8
- sing_box_cli-0.1.0/scripts/format.sh +0 -5
- sing_box_cli-0.1.0/scripts/lint.sh +0 -8
- sing_box_cli-0.1.0/scripts/pre-commit.sh +0 -9
- sing_box_cli-0.1.0/scripts/test.sh +0 -8
- sing_box_cli-0.1.0/src/sing_box_cli/bin/nssm.exe +0 -0
- sing_box_cli-0.1.0/tests/config/test_utils.py +0 -177
- sing_box_cli-0.1.0/tests/conftest.py +0 -11
- sing_box_cli-0.1.0/tests/data/config.json +0 -243
- sing_box_cli-0.1.0/uv.lock +0 -1036
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/.gitignore +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/LICENSE +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/api/__init__.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/api/client.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/api/connections.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/api/logs.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/api/monitor.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/api/policy.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/api/widget.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/common.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/config/__init__.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/config/utils.py +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/py.typed +0 -0
- {sing_box_cli-0.1.0 → sing_box_cli-0.1.2}/src/sing_box_cli/service/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: sing-box-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: cross-platform sing-box manager.
|
|
5
5
|
Project-URL: Homepage, https://github.com/aittcuszz/sing-box-service
|
|
6
6
|
Project-URL: Issues, https://github.com/atticuszz/sing-box-service/issues
|
|
@@ -69,31 +69,81 @@ Linux
|
|
|
69
69
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
### sing-box
|
|
73
|
-
|
|
74
|
-
Windows
|
|
72
|
+
### sing-box-cli
|
|
75
73
|
|
|
74
|
+
Install
|
|
76
75
|
```powershell
|
|
76
|
+
# windows
|
|
77
77
|
uv tool install sing-box-cli
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
Linux
|
|
81
|
-
|
|
82
80
|
```bash
|
|
81
|
+
# linux
|
|
83
82
|
uv tool install sing-box-cli
|
|
84
83
|
sudo ln -sf $(which sing-box-cli) /usr/local/bin/
|
|
84
|
+
sudo ln -sf $(which sbc) /usr/local/bin/
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Install with specific sing-box version
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
uv tool install sing-box-cli --with sing-box-bin==x.y.z
|
|
85
91
|
```
|
|
86
92
|
|
|
93
|
+
Upgrade
|
|
94
|
+
```bash
|
|
95
|
+
uv tool upgrade sing-box-cli
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
After upgrading on Windows, run the following in an administrator PowerShell:
|
|
99
|
+
|
|
100
|
+
```powershell
|
|
101
|
+
sbc service enable
|
|
102
|
+
sbc service status
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
This refreshes the installed service paths and migrates existing NSSM installations
|
|
106
|
+
to the native Go service host, keeping the same `sing-box-service` name and account.
|
|
107
|
+
An existing running service is stopped and restarted when its executable paths change.
|
|
108
|
+
If that update fails, the old service target is restored and the error is reported.
|
|
109
|
+
|
|
110
|
+
The native host and core are copied into versioned directories under
|
|
111
|
+
`%ProgramFiles%/sing-box-cli/sing-box-service`. The running service does not depend
|
|
112
|
+
on uv's environment, so future uv upgrades do not overwrite its running executables.
|
|
113
|
+
Configuration files stay in their existing location. Windows services run at boot;
|
|
114
|
+
closing the terminal does not stop them. Linux continues to use the existing systemd service.
|
|
115
|
+
|
|
116
|
+
No Go installation is required when installing a published wheel. Go is needed
|
|
117
|
+
only to build a wheel from source; the required version is in `windows-service/go.mod`.
|
|
118
|
+
Architecture support follows `sing-box-bin`: Windows distributes the amd64 core
|
|
119
|
+
and service helper; Linux distributes amd64 and arm64 cores. No additional CPU
|
|
120
|
+
architectures are introduced by the service helper.
|
|
121
|
+
|
|
122
|
+
### Logs
|
|
123
|
+
|
|
124
|
+
The service uses the user's sing-box configuration without changing its log level.
|
|
125
|
+
Set `log.level` to `info` or `warn` in that configuration to reduce generated logs.
|
|
126
|
+
`sbc logs --log-level debug` selects the logs to view; it cannot recover debug entries
|
|
127
|
+
that the core did not generate. For diagnostics, explicitly change the core's log level
|
|
128
|
+
and restart, then restore it when finished.
|
|
129
|
+
|
|
130
|
+
The native Windows host writes only warnings/errors to the Windows Application event
|
|
131
|
+
log (source `sing-box-service-native`). Core stdout/stderr is kept as a bounded diagnostic
|
|
132
|
+
tail for failures, without an additional debug log file. To persist core logs, use
|
|
133
|
+
sing-box's `log.output` configuration; streaming `sbc logs` continues to use the core API.
|
|
134
|
+
Old versioned executables are retained for rollback. After disabling the service,
|
|
135
|
+
its directory under Program Files may be removed manually; user configuration is separate.
|
|
136
|
+
|
|
87
137
|
## Run
|
|
88
138
|
|
|
89
139
|
Windows in Admin powershell
|
|
90
140
|
|
|
91
141
|
```powershell
|
|
92
|
-
|
|
142
|
+
sbc --help
|
|
93
143
|
```
|
|
94
144
|
|
|
95
145
|
Linux
|
|
96
146
|
|
|
97
147
|
```bash
|
|
98
|
-
sudo
|
|
148
|
+
sudo sbc --help
|
|
99
149
|
```
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Sing-Box CLI
|
|
2
|
+
|
|
3
|
+
🎤 Cross-platform sing-box service manager.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
### uv
|
|
10
|
+
|
|
11
|
+
Windows
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Linux
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### sing-box-cli
|
|
24
|
+
|
|
25
|
+
Install
|
|
26
|
+
```powershell
|
|
27
|
+
# windows
|
|
28
|
+
uv tool install sing-box-cli
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# linux
|
|
33
|
+
uv tool install sing-box-cli
|
|
34
|
+
sudo ln -sf $(which sing-box-cli) /usr/local/bin/
|
|
35
|
+
sudo ln -sf $(which sbc) /usr/local/bin/
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Install with specific sing-box version
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv tool install sing-box-cli --with sing-box-bin==x.y.z
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Upgrade
|
|
45
|
+
```bash
|
|
46
|
+
uv tool upgrade sing-box-cli
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
After upgrading on Windows, run the following in an administrator PowerShell:
|
|
50
|
+
|
|
51
|
+
```powershell
|
|
52
|
+
sbc service enable
|
|
53
|
+
sbc service status
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This refreshes the installed service paths and migrates existing NSSM installations
|
|
57
|
+
to the native Go service host, keeping the same `sing-box-service` name and account.
|
|
58
|
+
An existing running service is stopped and restarted when its executable paths change.
|
|
59
|
+
If that update fails, the old service target is restored and the error is reported.
|
|
60
|
+
|
|
61
|
+
The native host and core are copied into versioned directories under
|
|
62
|
+
`%ProgramFiles%/sing-box-cli/sing-box-service`. The running service does not depend
|
|
63
|
+
on uv's environment, so future uv upgrades do not overwrite its running executables.
|
|
64
|
+
Configuration files stay in their existing location. Windows services run at boot;
|
|
65
|
+
closing the terminal does not stop them. Linux continues to use the existing systemd service.
|
|
66
|
+
|
|
67
|
+
No Go installation is required when installing a published wheel. Go is needed
|
|
68
|
+
only to build a wheel from source; the required version is in `windows-service/go.mod`.
|
|
69
|
+
Architecture support follows `sing-box-bin`: Windows distributes the amd64 core
|
|
70
|
+
and service helper; Linux distributes amd64 and arm64 cores. No additional CPU
|
|
71
|
+
architectures are introduced by the service helper.
|
|
72
|
+
|
|
73
|
+
### Logs
|
|
74
|
+
|
|
75
|
+
The service uses the user's sing-box configuration without changing its log level.
|
|
76
|
+
Set `log.level` to `info` or `warn` in that configuration to reduce generated logs.
|
|
77
|
+
`sbc logs --log-level debug` selects the logs to view; it cannot recover debug entries
|
|
78
|
+
that the core did not generate. For diagnostics, explicitly change the core's log level
|
|
79
|
+
and restart, then restore it when finished.
|
|
80
|
+
|
|
81
|
+
The native Windows host writes only warnings/errors to the Windows Application event
|
|
82
|
+
log (source `sing-box-service-native`). Core stdout/stderr is kept as a bounded diagnostic
|
|
83
|
+
tail for failures, without an additional debug log file. To persist core logs, use
|
|
84
|
+
sing-box's `log.output` configuration; streaming `sbc logs` continues to use the core API.
|
|
85
|
+
Old versioned executables are retained for rollback. After disabling the service,
|
|
86
|
+
its directory under Program Files may be removed manually; user configuration is separate.
|
|
87
|
+
|
|
88
|
+
## Run
|
|
89
|
+
|
|
90
|
+
Windows in Admin powershell
|
|
91
|
+
|
|
92
|
+
```powershell
|
|
93
|
+
sbc --help
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Linux
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
sudo sbc --help
|
|
100
|
+
```
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Bundle the small Windows SCM helper; the CLI remains Python."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CustomBuildHook(BuildHookInterface):
|
|
13
|
+
def initialize(self, version: str, build_data: dict[str, Any]) -> None:
|
|
14
|
+
if self.target_name != "wheel" or version == "editable":
|
|
15
|
+
return
|
|
16
|
+
go = shutil.which("go")
|
|
17
|
+
if go is None:
|
|
18
|
+
raise RuntimeError(
|
|
19
|
+
"Building a wheel requires Go (see windows-service/go.mod). Installing a published wheel does not require Go."
|
|
20
|
+
)
|
|
21
|
+
root = Path(self.root)
|
|
22
|
+
output = root / "build" / "windows-service"
|
|
23
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
# Match sing-box-bin's single Windows amd64 resource.
|
|
25
|
+
filename = "sbc-service-windows-amd64.exe"
|
|
26
|
+
destination = output / filename
|
|
27
|
+
subprocess.run(
|
|
28
|
+
[
|
|
29
|
+
go,
|
|
30
|
+
"build",
|
|
31
|
+
"-mod=readonly",
|
|
32
|
+
"-trimpath",
|
|
33
|
+
"-ldflags=-s -w",
|
|
34
|
+
"-o",
|
|
35
|
+
str(destination),
|
|
36
|
+
".",
|
|
37
|
+
],
|
|
38
|
+
cwd=root / "windows-service",
|
|
39
|
+
env={
|
|
40
|
+
**os.environ,
|
|
41
|
+
"GOOS": "windows",
|
|
42
|
+
"GOARCH": "amd64",
|
|
43
|
+
"CGO_ENABLED": "0",
|
|
44
|
+
},
|
|
45
|
+
check=True,
|
|
46
|
+
)
|
|
47
|
+
build_data["force_include"][str(destination)] = f"sing_box_cli/bin/{filename}"
|
|
@@ -39,6 +39,7 @@ Changelog = "https://github.com/atticuszz/sing-box-service/blob/main/CHANGEL
|
|
|
39
39
|
|
|
40
40
|
[project.scripts] # build-backend config needed
|
|
41
41
|
sing-box-cli = "sing_box_cli:main" # equal to from python import main; then run main()
|
|
42
|
+
sbc = "sing_box_cli:main" # short alias for convenience
|
|
42
43
|
# FROM: https://packaging.python.org/en/latest/guides/writing-pyproject-toml/
|
|
43
44
|
|
|
44
45
|
[tool.uv]
|
|
@@ -70,6 +71,12 @@ path = "src/sing_box_cli/__init__.py"
|
|
|
70
71
|
packages = ["src/sing_box_cli"]
|
|
71
72
|
# FROM: https://hatch.pypa.io/latest/build/
|
|
72
73
|
|
|
74
|
+
[tool.hatch.build.hooks.custom]
|
|
75
|
+
path = "hatch_build.py"
|
|
76
|
+
|
|
77
|
+
[tool.hatch.build.targets.sdist]
|
|
78
|
+
include = ["/src", "/windows-service", "/hatch_build.py", "/pyproject.toml", "/README.md", "/LICENSE"]
|
|
79
|
+
|
|
73
80
|
## Test
|
|
74
81
|
[tool.mypy]
|
|
75
82
|
strict = true
|
|
@@ -208,7 +215,7 @@ topo_order = false
|
|
|
208
215
|
sort_commits = "oldest"
|
|
209
216
|
|
|
210
217
|
[tool.bumpversion]
|
|
211
|
-
current_version = "0.1.
|
|
218
|
+
current_version = "0.1.2"
|
|
212
219
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
213
220
|
serialize = ["{major}.{minor}.{patch}"]
|
|
214
221
|
search = "{current_version}"
|
|
@@ -315,5 +315,17 @@ def get_config() -> ConfigHandler:
|
|
|
315
315
|
return config
|
|
316
316
|
|
|
317
317
|
|
|
318
|
+
def run_args(config: ConfigHandler) -> list[str]:
|
|
319
|
+
"""Build subprocess arguments without splitting paths containing whitespace."""
|
|
320
|
+
return [
|
|
321
|
+
str(config.bin_path),
|
|
322
|
+
"run",
|
|
323
|
+
"-c",
|
|
324
|
+
str(config.config_file),
|
|
325
|
+
"-D",
|
|
326
|
+
str(config.config_dir),
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
|
|
318
330
|
def run_cmd(config: ConfigHandler) -> str:
|
|
319
331
|
return f"{config.bin_path} run -c {config.config_file} -D {config.config_dir}"
|
|
@@ -6,7 +6,7 @@ from rich import print
|
|
|
6
6
|
from sing_box_cli.api import api as api_app
|
|
7
7
|
from sing_box_cli.common import ClearCacheOption, UpdateConfigOption, ensure_root
|
|
8
8
|
from sing_box_cli.config import config as config_app
|
|
9
|
-
from sing_box_cli.config.config import get_config,
|
|
9
|
+
from sing_box_cli.config.config import get_config, run_args
|
|
10
10
|
from sing_box_cli.service import SharedContext, get_context_obj, service as service_app
|
|
11
11
|
from sing_box_cli.service.manager import create_service
|
|
12
12
|
|
|
@@ -47,7 +47,7 @@ def run(
|
|
|
47
47
|
raise typer.Exit(1)
|
|
48
48
|
|
|
49
49
|
# run
|
|
50
|
-
cmd =
|
|
50
|
+
cmd = run_args(cfg)
|
|
51
51
|
subprocess.run(cmd)
|
|
52
52
|
|
|
53
53
|
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import shutil
|
|
2
1
|
import subprocess
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
|
|
5
|
-
from ..config.config import ConfigHandler, run_cmd
|
|
4
|
+
from ..config.config import ConfigHandler, run_args, run_cmd
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
class ServiceManager:
|
|
@@ -35,151 +34,96 @@ class ServiceManager:
|
|
|
35
34
|
|
|
36
35
|
|
|
37
36
|
class WindowsServiceManager(ServiceManager):
|
|
38
|
-
"""
|
|
39
|
-
commands: https://nssm.cc/commands
|
|
40
|
-
binary: https://nssm.cc/builds
|
|
41
|
-
"""
|
|
37
|
+
"""Windows SCM service managed by the bundled native Go helper."""
|
|
42
38
|
|
|
43
39
|
def __init__(self, config: ConfigHandler) -> None:
|
|
44
40
|
super().__init__(config)
|
|
45
41
|
self.service_name = "sing-box-service"
|
|
46
|
-
self.status_list = ["SERVICE_RUNNING", "SERVICE_STOPPED", "SERVICE_PAUSED"]
|
|
47
42
|
|
|
48
43
|
@property
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return str(nssm_exe)
|
|
56
|
-
|
|
57
|
-
def create_service(self) -> None:
|
|
58
|
-
"""Create a Windows service using NSSM"""
|
|
59
|
-
# Install the service
|
|
60
|
-
try:
|
|
61
|
-
subprocess.run(
|
|
62
|
-
[
|
|
63
|
-
self.nssm_bin,
|
|
64
|
-
"install",
|
|
65
|
-
self.service_name,
|
|
66
|
-
*run_cmd(self.config).split(),
|
|
67
|
-
],
|
|
68
|
-
check=True,
|
|
69
|
-
stdout=subprocess.DEVNULL,
|
|
44
|
+
def helper_bin(self) -> str:
|
|
45
|
+
# Match sing-box-bin: Windows always ships the amd64 executable.
|
|
46
|
+
binary = Path(__file__).parents[1] / "bin" / "sbc-service-windows-amd64.exe"
|
|
47
|
+
if not binary.is_file():
|
|
48
|
+
raise FileNotFoundError(
|
|
49
|
+
f"Windows service helper not found: {binary}. Reinstall sing-box-cli from a built wheel."
|
|
70
50
|
)
|
|
71
|
-
|
|
72
|
-
# Service might already exist, which is fine for a create
|
|
73
|
-
pass
|
|
51
|
+
return str(binary)
|
|
74
52
|
|
|
75
|
-
|
|
53
|
+
def create_service(self) -> None:
|
|
54
|
+
self.check_service() # Fail on SCM access errors before attempting installation.
|
|
76
55
|
subprocess.run(
|
|
77
|
-
[
|
|
56
|
+
[
|
|
57
|
+
self.helper_bin,
|
|
58
|
+
"install",
|
|
59
|
+
self.service_name,
|
|
60
|
+
"--work-dir",
|
|
61
|
+
str(self.config.config_dir),
|
|
62
|
+
"--",
|
|
63
|
+
*run_args(self.config),
|
|
64
|
+
],
|
|
78
65
|
check=True,
|
|
79
66
|
stdout=subprocess.DEVNULL,
|
|
80
67
|
)
|
|
81
68
|
|
|
82
|
-
|
|
83
|
-
subprocess.run(
|
|
84
|
-
[self.
|
|
85
|
-
|
|
86
|
-
|
|
69
|
+
def check_service(self) -> bool:
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
[self.helper_bin, "exists", self.service_name],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
87
74
|
)
|
|
75
|
+
if result.returncode == 1060: # ERROR_SERVICE_DOES_NOT_EXIST
|
|
76
|
+
return False
|
|
77
|
+
result.check_returncode()
|
|
78
|
+
return True
|
|
88
79
|
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
def _service_state(self) -> str:
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
[self.helper_bin, "status", self.service_name],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
91
85
|
check=True,
|
|
92
|
-
stdout=subprocess.DEVNULL,
|
|
93
86
|
)
|
|
87
|
+
return result.stdout.strip()
|
|
94
88
|
|
|
95
|
-
|
|
89
|
+
def start(self) -> None:
|
|
90
|
+
if self.check_service() and self._service_state() == "SERVICE_RUNNING":
|
|
91
|
+
return
|
|
96
92
|
subprocess.run(
|
|
97
|
-
[
|
|
98
|
-
self.nssm_bin,
|
|
99
|
-
"set",
|
|
100
|
-
self.service_name,
|
|
101
|
-
"AppPriority",
|
|
102
|
-
"HIGH_PRIORITY_CLASS",
|
|
103
|
-
],
|
|
93
|
+
[self.helper_bin, "start", self.service_name],
|
|
104
94
|
check=True,
|
|
105
95
|
stdout=subprocess.DEVNULL,
|
|
106
96
|
)
|
|
107
97
|
|
|
108
|
-
|
|
98
|
+
def stop(self) -> None:
|
|
99
|
+
if not self.check_service() or self._service_state() == "SERVICE_STOPPED":
|
|
100
|
+
return
|
|
109
101
|
subprocess.run(
|
|
110
|
-
[
|
|
111
|
-
self.nssm_bin,
|
|
112
|
-
"set",
|
|
113
|
-
self.service_name,
|
|
114
|
-
"Type",
|
|
115
|
-
"SERVICE_WIN32_OWN_PROCESS",
|
|
116
|
-
],
|
|
102
|
+
[self.helper_bin, "stop", self.service_name],
|
|
117
103
|
check=True,
|
|
118
104
|
stdout=subprocess.DEVNULL,
|
|
119
105
|
)
|
|
120
106
|
|
|
121
|
-
def check_service(self) -> bool:
|
|
122
|
-
"""Check if the service exists"""
|
|
123
|
-
result = subprocess.run(
|
|
124
|
-
[self.nssm_bin, "status", self.service_name], capture_output=True, text=True
|
|
125
|
-
)
|
|
126
|
-
return result.stdout.strip() in self.status_list
|
|
127
|
-
|
|
128
|
-
def start(self) -> None:
|
|
129
|
-
"""Start the service"""
|
|
130
|
-
try:
|
|
131
|
-
subprocess.run(
|
|
132
|
-
[self.nssm_bin, "start", self.service_name],
|
|
133
|
-
check=True,
|
|
134
|
-
stdout=subprocess.DEVNULL,
|
|
135
|
-
)
|
|
136
|
-
except subprocess.CalledProcessError:
|
|
137
|
-
pass
|
|
138
|
-
|
|
139
|
-
def stop(self) -> None:
|
|
140
|
-
"""Stop the service"""
|
|
141
|
-
try:
|
|
142
|
-
subprocess.run(
|
|
143
|
-
[self.nssm_bin, "stop", self.service_name],
|
|
144
|
-
check=True,
|
|
145
|
-
stdout=subprocess.DEVNULL,
|
|
146
|
-
stderr=subprocess.DEVNULL,
|
|
147
|
-
)
|
|
148
|
-
except subprocess.CalledProcessError:
|
|
149
|
-
# Service might not be running, which is fine for a stop
|
|
150
|
-
pass
|
|
151
|
-
|
|
152
107
|
def restart(self) -> None:
|
|
153
|
-
"""Restart the service"""
|
|
154
108
|
subprocess.run(
|
|
155
|
-
[self.
|
|
109
|
+
[self.helper_bin, "restart", self.service_name],
|
|
156
110
|
check=True,
|
|
157
111
|
stdout=subprocess.DEVNULL,
|
|
158
|
-
stderr=subprocess.DEVNULL,
|
|
159
112
|
)
|
|
160
113
|
|
|
161
114
|
def status(self) -> str:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
166
|
-
return (
|
|
167
|
-
result.stdout.replace("_", " ").title().strip()
|
|
168
|
-
if result.stdout
|
|
169
|
-
else "Service not installed"
|
|
170
|
-
)
|
|
115
|
+
if not self.check_service():
|
|
116
|
+
return "Service not installed"
|
|
117
|
+
return self._service_state().replace("_", " ").title()
|
|
171
118
|
|
|
172
119
|
def disable(self) -> None:
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
except subprocess.CalledProcessError:
|
|
181
|
-
# Service might not exist, which is fine for a disable
|
|
182
|
-
pass
|
|
120
|
+
if not self.check_service():
|
|
121
|
+
return
|
|
122
|
+
subprocess.run(
|
|
123
|
+
[self.helper_bin, "remove", self.service_name],
|
|
124
|
+
check=True,
|
|
125
|
+
stdout=subprocess.DEVNULL,
|
|
126
|
+
)
|
|
183
127
|
|
|
184
128
|
def version(self) -> str:
|
|
185
129
|
result = subprocess.run([self.config.bin_path, "version"], capture_output=True)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"crypto/sha256"
|
|
5
|
+
"encoding/hex"
|
|
6
|
+
"errors"
|
|
7
|
+
"flag"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"regexp"
|
|
13
|
+
"sync"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type invocation struct {
|
|
17
|
+
command, name, directory string
|
|
18
|
+
args []string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func parseInvocation(args []string) (invocation, error) {
|
|
22
|
+
var in invocation
|
|
23
|
+
if len(args) < 2 {
|
|
24
|
+
return in, errors.New("usage: sbc-service <install|exists|status|start|stop|restart|remove|serve> <service-name> [--work-dir DIR -- PROGRAM ARGS...]")
|
|
25
|
+
}
|
|
26
|
+
in.command, in.name = args[0], args[1]
|
|
27
|
+
if !regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(in.name) {
|
|
28
|
+
return in, errors.New("invalid service name")
|
|
29
|
+
}
|
|
30
|
+
switch in.command {
|
|
31
|
+
case "install", "serve":
|
|
32
|
+
flags := flag.NewFlagSet(in.command, flag.ContinueOnError)
|
|
33
|
+
flags.SetOutput(io.Discard)
|
|
34
|
+
flags.StringVar(&in.directory, "work-dir", "", "working directory")
|
|
35
|
+
if err := flags.Parse(args[2:]); err != nil {
|
|
36
|
+
return in, err
|
|
37
|
+
}
|
|
38
|
+
in.args = flags.Args()
|
|
39
|
+
if !filepath.IsAbs(in.directory) || len(in.args) == 0 || !filepath.IsAbs(in.args[0]) {
|
|
40
|
+
return in, errors.New("absolute working directory and executable paths are required")
|
|
41
|
+
}
|
|
42
|
+
case "exists", "status", "start", "stop", "restart", "remove":
|
|
43
|
+
if len(args) != 2 {
|
|
44
|
+
return in, errors.New("unexpected command arguments")
|
|
45
|
+
}
|
|
46
|
+
default:
|
|
47
|
+
return in, fmt.Errorf("unknown command: %s", in.command)
|
|
48
|
+
}
|
|
49
|
+
return in, nil
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Never overwrite an executable that another service or uv environment is using.
|
|
53
|
+
func stageBinary(source, root, filename string) (string, error) {
|
|
54
|
+
f, err := os.Open(source)
|
|
55
|
+
if err != nil {
|
|
56
|
+
return "", err
|
|
57
|
+
}
|
|
58
|
+
defer f.Close()
|
|
59
|
+
hash := sha256.New()
|
|
60
|
+
if _, err := io.Copy(hash, f); err != nil {
|
|
61
|
+
return "", err
|
|
62
|
+
}
|
|
63
|
+
directory := filepath.Join(root, hex.EncodeToString(hash.Sum(nil)))
|
|
64
|
+
if err := os.MkdirAll(directory, 0755); err != nil {
|
|
65
|
+
return "", err
|
|
66
|
+
}
|
|
67
|
+
destination := filepath.Join(directory, filename)
|
|
68
|
+
// Verify cached contents too; a partial copy must never become the service target.
|
|
69
|
+
if data, err := os.ReadFile(destination); err == nil {
|
|
70
|
+
sum := sha256.Sum256(data)
|
|
71
|
+
if hex.EncodeToString(sum[:]) == filepath.Base(directory) {
|
|
72
|
+
return destination, nil
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
|
76
|
+
return "", err
|
|
77
|
+
}
|
|
78
|
+
tmp, err := os.CreateTemp(directory, "copy-*.exe")
|
|
79
|
+
if err != nil {
|
|
80
|
+
return "", err
|
|
81
|
+
}
|
|
82
|
+
defer os.Remove(tmp.Name())
|
|
83
|
+
_, copyErr := io.Copy(tmp, f)
|
|
84
|
+
syncErr := tmp.Sync()
|
|
85
|
+
closeErr := tmp.Close()
|
|
86
|
+
if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
|
|
87
|
+
return "", err
|
|
88
|
+
}
|
|
89
|
+
if err := os.Rename(tmp.Name(), destination); err != nil {
|
|
90
|
+
return "", err
|
|
91
|
+
}
|
|
92
|
+
return destination, nil
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Only keep a bounded diagnostic tail, never an unbounded service debug log.
|
|
96
|
+
type diagnosticTail struct {
|
|
97
|
+
mu sync.Mutex
|
|
98
|
+
data []byte
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func (t *diagnosticTail) Write(p []byte) (int, error) {
|
|
102
|
+
t.mu.Lock()
|
|
103
|
+
defer t.mu.Unlock()
|
|
104
|
+
const limit = 8192
|
|
105
|
+
n := len(p)
|
|
106
|
+
if len(p) >= limit {
|
|
107
|
+
t.data = append(t.data[:0], p[len(p)-limit:]...)
|
|
108
|
+
} else {
|
|
109
|
+
t.data = append(t.data, p...)
|
|
110
|
+
if len(t.data) > limit {
|
|
111
|
+
t.data = t.data[len(t.data)-limit:]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return n, nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func (t *diagnosticTail) String() string {
|
|
118
|
+
t.mu.Lock()
|
|
119
|
+
defer t.mu.Unlock()
|
|
120
|
+
return string(t.data)
|
|
121
|
+
}
|