sing-box-cli 0.1.4__tar.gz → 0.1.6__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.4 → sing_box_cli-0.1.6}/PKG-INFO +39 -2
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/README.md +38 -1
- sing_box_cli-0.1.6/docs/config-serve.md +108 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/pyproject.toml +1 -1
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/__init__.py +1 -1
- sing_box_cli-0.1.6/src/sing_box_cli/config/__init__.py +97 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/config/config.py +25 -11
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/config/serve.py +134 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/config/serve_backend.py +14 -1
- sing_box_cli-0.1.6/src/sing_box_cli/config/serve_tokens.py +151 -0
- sing_box_cli-0.1.6/src/sing_box_cli/config/utils.py +92 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/main.py +3 -1
- sing_box_cli-0.1.4/docs/config-serve.md +0 -55
- sing_box_cli-0.1.4/src/sing_box_cli/config/__init__.py +0 -78
- sing_box_cli-0.1.4/src/sing_box_cli/config/utils.py +0 -52
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/.gitignore +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/LICENSE +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/docs/traefik-config-serve.yml +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/hatch_build.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/__init__.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/client.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/connections.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/logs.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/monitor.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/policy.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/widget.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/common.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/config/_serve_worker.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/installation.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/py.typed +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/service/__init__.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/service/manager.py +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/common.go +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/common_test.go +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/go.mod +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/go.sum +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/integration_windows_test.go +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/main_other.go +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/main_windows.go +0 -0
- {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/testdata/core.go +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sing-box-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
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
|
|
@@ -160,6 +160,33 @@ Linux
|
|
|
160
160
|
sudo sbc --help
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
+
## Configuration
|
|
164
|
+
|
|
165
|
+
Preview an update before applying it:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
sbc config update https://sub.example.com/alice.json --dry-run
|
|
169
|
+
sbc config update https://sub.example.com/alice.json --restart
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`--dry-run` downloads and validates the configuration, then shows its diff without
|
|
173
|
+
saving configuration, subscription URL or token. It does not create local config
|
|
174
|
+
files or change their ownership. If combined with `--restart`, the service is left
|
|
175
|
+
running without a restart.
|
|
176
|
+
|
|
177
|
+
Subscription downloads retry once over IPv4 if the default connection fails or
|
|
178
|
+
times out. Configured environment proxies are preserved: this direct IPv4 retry
|
|
179
|
+
is skipped when a proxy is configured. HTTP errors such as 401/403 are not retried.
|
|
180
|
+
|
|
181
|
+
Use one command to choose what to print:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
sbc config get # configuration JSON
|
|
185
|
+
sbc config get --subscription # subscription URL
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`config show` and `config show-sub` remain available as hidden compatibility aliases.
|
|
189
|
+
|
|
163
190
|
## Serve private GitHub configurations
|
|
164
191
|
|
|
165
192
|
Set `GH_TOKEN` (or `GITHUB_TOKEN`) with **Contents: read** access to your private
|
|
@@ -168,6 +195,7 @@ repository, then run as your normal user:
|
|
|
168
195
|
```bash
|
|
169
196
|
sbc config serve add https://github.com/owner/private-configs --domain sub.example.com
|
|
170
197
|
sbc config serve list
|
|
198
|
+
sbc token -c alice.json
|
|
171
199
|
sbc config serve start
|
|
172
200
|
sbc config serve stop
|
|
173
201
|
```
|
|
@@ -176,11 +204,20 @@ sbc config serve stop
|
|
|
176
204
|
through its API without cloning; `start` runs in the background at `127.0.0.1:8080`
|
|
177
205
|
by default. Use `--host` and `--port` for another listening address.
|
|
178
206
|
|
|
207
|
+
Subscription downloads require a separate token. `sbc token -c FILE.json` prints its secret
|
|
208
|
+
and a URL containing `?token=...` once; only its hash is stored on the server.
|
|
209
|
+
Use `sbc token -l` to list tokens, `sbc token -r TOKEN` to revoke one, or
|
|
210
|
+
`sbc token -r FILE.json` to revoke all tokens for a file. Creation infers a single
|
|
211
|
+
registered domain; use `-d DOMAIN` when there are multiple domains. Each token is
|
|
212
|
+
limited to its domain and file. The GitHub PAT stays on the server.
|
|
213
|
+
|
|
179
214
|
Point Traefik at the server using [this example](docs/traefik-config-serve.yml).
|
|
180
215
|
Clients continue to use the existing update command:
|
|
181
216
|
|
|
182
217
|
```bash
|
|
183
|
-
sbc config update https://sub.example.com/alice.json --restart
|
|
218
|
+
sbc config update https://sub.example.com/alice.json -t '<subscription-token>' --restart
|
|
219
|
+
# Apps without header options can use the token URL printed by token -c.
|
|
220
|
+
sbc config update 'https://sub.example.com/alice.json?token=<subscription-token>' --dry-run
|
|
184
221
|
```
|
|
185
222
|
|
|
186
223
|
See [configuration server details](docs/config-serve.md) for settings, logs and limits.
|
|
@@ -111,6 +111,33 @@ Linux
|
|
|
111
111
|
sudo sbc --help
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
+
## Configuration
|
|
115
|
+
|
|
116
|
+
Preview an update before applying it:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
sbc config update https://sub.example.com/alice.json --dry-run
|
|
120
|
+
sbc config update https://sub.example.com/alice.json --restart
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`--dry-run` downloads and validates the configuration, then shows its diff without
|
|
124
|
+
saving configuration, subscription URL or token. It does not create local config
|
|
125
|
+
files or change their ownership. If combined with `--restart`, the service is left
|
|
126
|
+
running without a restart.
|
|
127
|
+
|
|
128
|
+
Subscription downloads retry once over IPv4 if the default connection fails or
|
|
129
|
+
times out. Configured environment proxies are preserved: this direct IPv4 retry
|
|
130
|
+
is skipped when a proxy is configured. HTTP errors such as 401/403 are not retried.
|
|
131
|
+
|
|
132
|
+
Use one command to choose what to print:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
sbc config get # configuration JSON
|
|
136
|
+
sbc config get --subscription # subscription URL
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`config show` and `config show-sub` remain available as hidden compatibility aliases.
|
|
140
|
+
|
|
114
141
|
## Serve private GitHub configurations
|
|
115
142
|
|
|
116
143
|
Set `GH_TOKEN` (or `GITHUB_TOKEN`) with **Contents: read** access to your private
|
|
@@ -119,6 +146,7 @@ repository, then run as your normal user:
|
|
|
119
146
|
```bash
|
|
120
147
|
sbc config serve add https://github.com/owner/private-configs --domain sub.example.com
|
|
121
148
|
sbc config serve list
|
|
149
|
+
sbc token -c alice.json
|
|
122
150
|
sbc config serve start
|
|
123
151
|
sbc config serve stop
|
|
124
152
|
```
|
|
@@ -127,11 +155,20 @@ sbc config serve stop
|
|
|
127
155
|
through its API without cloning; `start` runs in the background at `127.0.0.1:8080`
|
|
128
156
|
by default. Use `--host` and `--port` for another listening address.
|
|
129
157
|
|
|
158
|
+
Subscription downloads require a separate token. `sbc token -c FILE.json` prints its secret
|
|
159
|
+
and a URL containing `?token=...` once; only its hash is stored on the server.
|
|
160
|
+
Use `sbc token -l` to list tokens, `sbc token -r TOKEN` to revoke one, or
|
|
161
|
+
`sbc token -r FILE.json` to revoke all tokens for a file. Creation infers a single
|
|
162
|
+
registered domain; use `-d DOMAIN` when there are multiple domains. Each token is
|
|
163
|
+
limited to its domain and file. The GitHub PAT stays on the server.
|
|
164
|
+
|
|
130
165
|
Point Traefik at the server using [this example](docs/traefik-config-serve.yml).
|
|
131
166
|
Clients continue to use the existing update command:
|
|
132
167
|
|
|
133
168
|
```bash
|
|
134
|
-
sbc config update https://sub.example.com/alice.json --restart
|
|
169
|
+
sbc config update https://sub.example.com/alice.json -t '<subscription-token>' --restart
|
|
170
|
+
# Apps without header options can use the token URL printed by token -c.
|
|
171
|
+
sbc config update 'https://sub.example.com/alice.json?token=<subscription-token>' --dry-run
|
|
135
172
|
```
|
|
136
173
|
|
|
137
174
|
See [configuration server details](docs/config-serve.md) for settings, logs and limits.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Configuration server
|
|
2
|
+
|
|
3
|
+
The server maps one domain to one private GitHub repository. Multiple domains can
|
|
4
|
+
share a server. Set `GH_TOKEN` or `GITHUB_TOKEN` to a fine-grained token with
|
|
5
|
+
**Contents: read** access to all registered repositories.
|
|
6
|
+
|
|
7
|
+
`config serve add` checks GitHub access and saves the binding; registering the same
|
|
8
|
+
binding again is safe. `config serve list` validates root `.json` files with the
|
|
9
|
+
sing-box core bundled with this package, prints valid URLs, and reports invalid
|
|
10
|
+
configurations on stderr. Those URLs require a separate subscription token;
|
|
11
|
+
the GitHub PAT stays on the server and is never used as a client credential.
|
|
12
|
+
|
|
13
|
+
## Subscription tokens
|
|
14
|
+
|
|
15
|
+
Use the short top-level command to manage subscription tokens:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
sbc token -c client-linux.json # create a token
|
|
19
|
+
sbc token -l # list token names and scopes
|
|
20
|
+
sbc token -r '<subscription-token>' # revoke this exact token
|
|
21
|
+
sbc token -r client-linux.json # revoke all tokens for this file
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`-c FILE` prints the secret and a ready-to-use URL once. Store the secret when it is
|
|
25
|
+
shown; the server keeps only its SHA-256 hash in `tokens.json`. `-l` prints names,
|
|
26
|
+
domains, filenames and creation times without revealing secrets. Names are generated
|
|
27
|
+
automatically; optionally use `-n NAME` for a meaningful label. When multiple domains
|
|
28
|
+
are registered, add `-d DOMAIN` on creation. If a filename has tokens on multiple
|
|
29
|
+
domains, revoking by file also requires `-d DOMAIN` to choose one domain.
|
|
30
|
+
|
|
31
|
+
`-r TOKEN` matches the actual secret against the stored hash. `-r FILE.json` revokes
|
|
32
|
+
every token for that file without affecting other files. Multiple tokens may
|
|
33
|
+
authorize the same file, so rotation can create a replacement before revoking the
|
|
34
|
+
old token. Revocation never prints the secret.
|
|
35
|
+
|
|
36
|
+
The longer `config serve token -l/-c/-r` forms remain supported. The previous
|
|
37
|
+
`config serve token create/list/revoke` subcommands remain hidden compatibility
|
|
38
|
+
aliases; the legacy `revoke NAME` form still accepts a token name.
|
|
39
|
+
|
|
40
|
+
Clients can send the token as a Bearer header using `-t` / `--token`, or use the
|
|
41
|
+
printed URL with `?token=...` in applications that cannot set headers:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
sbc config update https://sub.example.com/client-linux.json -t '<subscription-token>' --dry-run
|
|
45
|
+
sbc config update 'https://sub.example.com/client-linux.json?token=<subscription-token>' --dry-run
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The CLI extracts URL tokens before printing or saving the URL, sends them as Bearer
|
|
49
|
+
credentials, and saves the clean URL and token separately on a normal update. URL
|
|
50
|
+
tokens take precedence over a previously saved token. Conflicting URL and explicit
|
|
51
|
+
`--token` credentials are rejected. `--dry-run` saves neither form.
|
|
52
|
+
|
|
53
|
+
The server accepts either form directly. Missing, incorrect, revoked or out-of-scope
|
|
54
|
+
credentials return **401** before any GitHub read. If both forms are supplied, they
|
|
55
|
+
must match. No token settings means all downloads are denied; unreadable or corrupt
|
|
56
|
+
settings also deny access. Token creation and revocation take effect on the next
|
|
57
|
+
request without restarting. A token cannot read another domain or filename.
|
|
58
|
+
|
|
59
|
+
Use HTTPS for public subscriptions. The service does not log request URLs or tokens;
|
|
60
|
+
keep token query strings out of reverse-proxy access logs as well.
|
|
61
|
+
|
|
62
|
+
## Background process
|
|
63
|
+
|
|
64
|
+
`config serve start` waits until the socket is listening, then prints the PID,
|
|
65
|
+
address and log path. Closing the terminal leaves the server running. Repeated
|
|
66
|
+
starts use the existing process, and repeated stops are safe. Run `stop` as the
|
|
67
|
+
same user with the same state directory. Restart after changing the GitHub token
|
|
68
|
+
or upgrading the package. Existing deployments must upgrade and restart to enable
|
|
69
|
+
subscription authentication; anonymous URLs then require a newly generated token.
|
|
70
|
+
Boot-time startup is managed separately by deployment.
|
|
71
|
+
|
|
72
|
+
Settings, process identity and `serve.log` are stored in the platform's
|
|
73
|
+
`sing-box-cli/serve` application directory (`~/.config/sing-box-cli/serve` on Linux).
|
|
74
|
+
Set `SBC_SERVE_DIR` to use another directory for all commands. Subscription token
|
|
75
|
+
hashes are stored there; GitHub PATs, subscription secrets and client configuration
|
|
76
|
+
are not. New registrations take effect while running.
|
|
77
|
+
|
|
78
|
+
## GitHub reads and validation
|
|
79
|
+
|
|
80
|
+
Requests read the repository's default branch through the GitHub Contents API.
|
|
81
|
+
No Git installation, clone/pull, background synchronization or persistent config
|
|
82
|
+
cache is needed. File updates and deletions take effect on subsequent requests.
|
|
83
|
+
|
|
84
|
+
Each response is validated and preserves the original content. Missing files,
|
|
85
|
+
invalid configurations and GitHub errors return HTTP errors. Configurations are
|
|
86
|
+
limited to 5 MiB and must be compatible with the bundled core. Referenced sibling
|
|
87
|
+
files are not downloaded. Existing client `--token` options and saved settings remain
|
|
88
|
+
supported; use an independent subscription token for this server's URLs.
|
|
89
|
+
|
|
90
|
+
Android's `route.override_android_vpn: true` is disabled only in the temporary
|
|
91
|
+
core-check copy, so a Linux or Windows server can distribute Android configurations.
|
|
92
|
+
Responses retain the original bytes, including the Android setting. Invalid
|
|
93
|
+
option types, unknown fields and other core errors are still rejected; this does
|
|
94
|
+
not verify the target device's runtime environment.
|
|
95
|
+
|
|
96
|
+
## Traefik
|
|
97
|
+
|
|
98
|
+
Merge [the example router and service](traefik-config-serve.yml) into the existing
|
|
99
|
+
`http.routers` and `http.services` sections. Traefik must preserve the `Host` header.
|
|
100
|
+
The example uses `websecure` and the host gateway from `digitalocean-public`'s
|
|
101
|
+
`~/devspace/traefik.yml`. Bind to that gateway for this deployment:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
sbc config serve start --host 172.18.0.1 --port 8080
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Replace `sub.example.com` with your domain and configure DNS and the TLS
|
|
108
|
+
certificate through your deployment. `--domain` only registers the binding.
|
|
@@ -215,7 +215,7 @@ topo_order = false
|
|
|
215
215
|
sort_commits = "oldest"
|
|
216
216
|
|
|
217
217
|
[tool.bumpversion]
|
|
218
|
-
current_version = "0.1.
|
|
218
|
+
current_version = "0.1.6"
|
|
219
219
|
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
220
220
|
serialize = ["{major}.{minor}.{patch}"]
|
|
221
221
|
search = "{current_version}"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich import print
|
|
5
|
+
|
|
6
|
+
from ..common import StrOrNone, ensure_root
|
|
7
|
+
from ..service import SharedContext, get_context_obj
|
|
8
|
+
from ..service.manager import create_service
|
|
9
|
+
from .config import get_config
|
|
10
|
+
from .serve import serve
|
|
11
|
+
|
|
12
|
+
__all__ = ["config"]
|
|
13
|
+
|
|
14
|
+
SubUrlArg = Annotated[str, typer.Argument(help="Subscription URL")]
|
|
15
|
+
TokenOption = Annotated[
|
|
16
|
+
StrOrNone,
|
|
17
|
+
typer.Option(
|
|
18
|
+
"--token", "-t", help="Subscription access token (not the server's GitHub PAT)."
|
|
19
|
+
),
|
|
20
|
+
]
|
|
21
|
+
RestartServiceOption = Annotated[
|
|
22
|
+
bool, typer.Option("--restart", "-r", help="Restart service after update.")
|
|
23
|
+
]
|
|
24
|
+
DryRunOption = Annotated[
|
|
25
|
+
bool,
|
|
26
|
+
typer.Option(
|
|
27
|
+
"--dry-run", help="Show configuration diff without saving or restarting."
|
|
28
|
+
),
|
|
29
|
+
]
|
|
30
|
+
config = typer.Typer(help="Configuration management commands")
|
|
31
|
+
config.add_typer(serve, name="serve")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@config.callback()
|
|
35
|
+
def config_callback(ctx: typer.Context) -> None:
|
|
36
|
+
if ctx.invoked_subcommand not in ("serve", "update"):
|
|
37
|
+
cfg = get_config(initialize=ctx.invoked_subcommand == "clear_cache")
|
|
38
|
+
ctx.obj = SharedContext(config=cfg, service=create_service(cfg))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@config.command("update")
|
|
42
|
+
def config_update(
|
|
43
|
+
url: SubUrlArg,
|
|
44
|
+
token: TokenOption = None,
|
|
45
|
+
restart: RestartServiceOption = False,
|
|
46
|
+
dry_run: DryRunOption = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Download configuration or preview its diff with --dry-run."""
|
|
49
|
+
cfg = get_config(initialize=not dry_run)
|
|
50
|
+
if not cfg.update_config(url, token, dry_run=dry_run):
|
|
51
|
+
print("❌ Failed to update configuration.")
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
if restart and not dry_run:
|
|
54
|
+
ensure_root()
|
|
55
|
+
service = create_service(cfg)
|
|
56
|
+
# init service
|
|
57
|
+
if not service.check_service():
|
|
58
|
+
service.create_service()
|
|
59
|
+
print("⌛ Service created successfully.")
|
|
60
|
+
service.restart()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@config.command("get")
|
|
64
|
+
def config_get(
|
|
65
|
+
ctx: typer.Context,
|
|
66
|
+
subscription: Annotated[
|
|
67
|
+
bool, typer.Option("--subscription", help="Print the subscription URL.")
|
|
68
|
+
] = False,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Print configuration, or select the subscription URL with --subscription."""
|
|
71
|
+
cfg = get_context_obj(ctx).config
|
|
72
|
+
if subscription:
|
|
73
|
+
if cfg.sub_url:
|
|
74
|
+
print(f"🔗 Current subscription URL: {cfg.sub_url}")
|
|
75
|
+
else:
|
|
76
|
+
print("❌ No subscription URL found.")
|
|
77
|
+
else:
|
|
78
|
+
print(cfg.config_file_content)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@config.command("show-sub", hidden=True)
|
|
82
|
+
def config_show_sub(ctx: typer.Context) -> None:
|
|
83
|
+
"""Compatibility alias for config get --subscription."""
|
|
84
|
+
config_get(ctx, subscription=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@config.command("show", hidden=True)
|
|
88
|
+
def config_show(ctx: typer.Context) -> None:
|
|
89
|
+
"""Compatibility alias for config get."""
|
|
90
|
+
config_get(ctx)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@config.command("clear_cache")
|
|
94
|
+
def config_clear_cache(ctx: typer.Context) -> None:
|
|
95
|
+
"""Clean cache database"""
|
|
96
|
+
ctx_obj = get_context_obj(ctx)
|
|
97
|
+
ctx_obj.config.clear_cache()
|
|
@@ -11,7 +11,7 @@ from rich import print
|
|
|
11
11
|
from sing_box_bin import get_bin_path
|
|
12
12
|
|
|
13
13
|
from ..common import StrOrNone
|
|
14
|
-
from .utils import request_get, show_diff_config
|
|
14
|
+
from .utils import request_get, show_diff_config, subscription_credentials
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class ClashApiConfig(BaseModel):
|
|
@@ -239,8 +239,14 @@ class ConfigHandler:
|
|
|
239
239
|
self._app_config.save(self._app_config_file)
|
|
240
240
|
print("🔑 Token added successfully.")
|
|
241
241
|
|
|
242
|
-
def update_config(
|
|
243
|
-
|
|
242
|
+
def update_config(
|
|
243
|
+
self,
|
|
244
|
+
sub_url: StrOrNone = None,
|
|
245
|
+
token: StrOrNone = None,
|
|
246
|
+
*,
|
|
247
|
+
dry_run: bool = False,
|
|
248
|
+
) -> bool:
|
|
249
|
+
"""Download and diff configuration, optionally without saving changes."""
|
|
244
250
|
try:
|
|
245
251
|
if sub_url is None:
|
|
246
252
|
# load from file
|
|
@@ -248,10 +254,12 @@ class ConfigHandler:
|
|
|
248
254
|
print("❌ No subscription URL found.")
|
|
249
255
|
return False
|
|
250
256
|
sub_url = self.sub_url
|
|
257
|
+
sub_url, token = subscription_credentials(sub_url, token)
|
|
251
258
|
if token is None:
|
|
252
259
|
# load from file
|
|
253
260
|
token = self.token_content
|
|
254
|
-
|
|
261
|
+
action = "Previewing" if dry_run else "Updating"
|
|
262
|
+
print(f"⌛ {action} configuration from {sub_url}")
|
|
255
263
|
response = request_get(sub_url, token)
|
|
256
264
|
if response is None:
|
|
257
265
|
print("❌ Failed to get configuration.")
|
|
@@ -259,9 +267,6 @@ class ConfigHandler:
|
|
|
259
267
|
|
|
260
268
|
new_config = response.text
|
|
261
269
|
|
|
262
|
-
if not self.is_windows:
|
|
263
|
-
shutil.chown(self._config_file, user=self.user, group=self.user)
|
|
264
|
-
|
|
265
270
|
# make sure same order of keys to avoid showing wrong diff
|
|
266
271
|
new_config = self._sing_box_config.model_validate_json(
|
|
267
272
|
new_config
|
|
@@ -269,8 +274,17 @@ class ConfigHandler:
|
|
|
269
274
|
if self.config_file_content == new_config:
|
|
270
275
|
print("📄 Configuration is up to date.")
|
|
271
276
|
else:
|
|
272
|
-
#
|
|
277
|
+
# show differences before saving
|
|
273
278
|
show_diff_config(self.config_file_content, new_config)
|
|
279
|
+
|
|
280
|
+
if dry_run:
|
|
281
|
+
print("📄 Dry run: no configuration or subscription changes saved.")
|
|
282
|
+
return True
|
|
283
|
+
|
|
284
|
+
if not self.is_windows:
|
|
285
|
+
shutil.chown(self._config_file, user=self.user, group=self.user)
|
|
286
|
+
|
|
287
|
+
if self.config_file_content != new_config:
|
|
274
288
|
self.config_file_content = new_config
|
|
275
289
|
|
|
276
290
|
# Update subscription url file
|
|
@@ -307,10 +321,10 @@ class ConfigHandler:
|
|
|
307
321
|
return info
|
|
308
322
|
|
|
309
323
|
|
|
310
|
-
def get_config() -> ConfigHandler:
|
|
311
|
-
"""
|
|
324
|
+
def get_config(*, initialize: bool = True) -> ConfigHandler:
|
|
325
|
+
"""Load configuration, optionally initializing local directories and files."""
|
|
312
326
|
config = ConfigHandler()
|
|
313
|
-
if not config.init_directories():
|
|
327
|
+
if initialize and not config.init_directories():
|
|
314
328
|
raise FileNotFoundError("❌ Failed to initialize directories")
|
|
315
329
|
return config
|
|
316
330
|
|
|
@@ -25,9 +25,17 @@ from .serve_backend import (
|
|
|
25
25
|
state_directory,
|
|
26
26
|
validate_config,
|
|
27
27
|
)
|
|
28
|
+
from .serve_tokens import (
|
|
29
|
+
create_token,
|
|
30
|
+
load_tokens,
|
|
31
|
+
revoke_subscription_tokens,
|
|
32
|
+
revoke_token,
|
|
33
|
+
)
|
|
28
34
|
|
|
29
35
|
WORKER_MODULE = "sing_box_cli.config._serve_worker"
|
|
30
36
|
serve = typer.Typer(help="Serve private GitHub configurations through your own domain.")
|
|
37
|
+
tokens = typer.Typer(help="Create, list and revoke independent subscription tokens.")
|
|
38
|
+
serve.add_typer(tokens, name="token")
|
|
31
39
|
|
|
32
40
|
|
|
33
41
|
class ProcessState(BaseModel):
|
|
@@ -270,6 +278,132 @@ def start(
|
|
|
270
278
|
print(f"Log: {directory / 'serve.log'}")
|
|
271
279
|
|
|
272
280
|
|
|
281
|
+
@tokens.callback(invoke_without_command=True)
|
|
282
|
+
def token_options(
|
|
283
|
+
ctx: typer.Context,
|
|
284
|
+
list_: Annotated[
|
|
285
|
+
bool, typer.Option("--list", "-l", help="List token names and file scopes.")
|
|
286
|
+
] = False,
|
|
287
|
+
create: Annotated[
|
|
288
|
+
str | None,
|
|
289
|
+
typer.Option(
|
|
290
|
+
"--create", "-c", help="Create a token for this configuration filename."
|
|
291
|
+
),
|
|
292
|
+
] = None,
|
|
293
|
+
revoke: Annotated[
|
|
294
|
+
str | None,
|
|
295
|
+
typer.Option(
|
|
296
|
+
"--revoke",
|
|
297
|
+
"-r",
|
|
298
|
+
help="Revoke this token, or all tokens for this .json filename.",
|
|
299
|
+
),
|
|
300
|
+
] = None,
|
|
301
|
+
domain: Annotated[
|
|
302
|
+
str | None,
|
|
303
|
+
typer.Option(
|
|
304
|
+
"--domain",
|
|
305
|
+
"-d",
|
|
306
|
+
help="Domain; inferred when there is only one registered domain.",
|
|
307
|
+
),
|
|
308
|
+
] = None,
|
|
309
|
+
name: Annotated[
|
|
310
|
+
str | None, typer.Option("--name", "-n", help="Optional name for a new token.")
|
|
311
|
+
] = None,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Manage subscription tokens with -l, -c FILE or -r TOKEN_OR_FILE."""
|
|
314
|
+
actions = int(list_) + int(create is not None) + int(revoke is not None)
|
|
315
|
+
if ctx.invoked_subcommand is not None:
|
|
316
|
+
if actions or domain is not None or name is not None:
|
|
317
|
+
raise typer.BadParameter("Use options or a legacy subcommand, not both.")
|
|
318
|
+
return
|
|
319
|
+
if actions == 0 and domain is None and name is None:
|
|
320
|
+
print(ctx.get_help())
|
|
321
|
+
return
|
|
322
|
+
if actions != 1:
|
|
323
|
+
raise typer.BadParameter(
|
|
324
|
+
"Choose exactly one of -l, -c FILE or -r TOKEN_OR_FILE."
|
|
325
|
+
)
|
|
326
|
+
if name is not None and create is None:
|
|
327
|
+
raise typer.BadParameter("--name is only used with --create.")
|
|
328
|
+
if list_:
|
|
329
|
+
if domain is not None:
|
|
330
|
+
raise typer.BadParameter("--domain is only used with --create or --revoke.")
|
|
331
|
+
token_list()
|
|
332
|
+
elif create is not None:
|
|
333
|
+
with command_errors():
|
|
334
|
+
if domain is None:
|
|
335
|
+
sources = load_sources(state_directory())
|
|
336
|
+
if len(sources) != 1:
|
|
337
|
+
raise ServeError(
|
|
338
|
+
"Specify --domain, or register a single domain first.", 400
|
|
339
|
+
)
|
|
340
|
+
domain = sources[0].domain
|
|
341
|
+
token_create(name or f"token-{uuid.uuid4().hex[:12]}", domain, create)
|
|
342
|
+
else:
|
|
343
|
+
assert revoke is not None
|
|
344
|
+
with command_errors():
|
|
345
|
+
directory = state_directory()
|
|
346
|
+
with control_lock(directory):
|
|
347
|
+
count = revoke_subscription_tokens(directory, revoke, domain)
|
|
348
|
+
print(f"Revoked {count} subscription token(s).")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@tokens.command("create", hidden=True)
|
|
352
|
+
def token_create(
|
|
353
|
+
name: Annotated[
|
|
354
|
+
str, typer.Argument(help="Unique name for this subscription token.")
|
|
355
|
+
],
|
|
356
|
+
domain: Annotated[
|
|
357
|
+
str, typer.Option("--domain", help="Registered subscription domain.")
|
|
358
|
+
],
|
|
359
|
+
filename: Annotated[
|
|
360
|
+
str, typer.Option("--file", help="Root configuration filename.")
|
|
361
|
+
],
|
|
362
|
+
) -> None:
|
|
363
|
+
"""Generate a file-scoped token and print its secret and URL once."""
|
|
364
|
+
with command_errors():
|
|
365
|
+
domain = normalize_domain(domain)
|
|
366
|
+
directory = state_directory()
|
|
367
|
+
with control_lock(directory):
|
|
368
|
+
source = next(
|
|
369
|
+
(item for item in load_sources(directory) if item.domain == domain),
|
|
370
|
+
None,
|
|
371
|
+
)
|
|
372
|
+
if source is None:
|
|
373
|
+
raise ServeError(
|
|
374
|
+
"Register this domain with config serve add first.", 400
|
|
375
|
+
)
|
|
376
|
+
secret = create_token(directory, name, domain, filename)
|
|
377
|
+
print(f"Token: {secret}")
|
|
378
|
+
print(f"URL: {source.url(filename)}?token={secret}")
|
|
379
|
+
print("Save the token now; only its hash is stored on the server.")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@tokens.command("list", hidden=True)
|
|
383
|
+
def token_list() -> None:
|
|
384
|
+
"""List token names and scopes without displaying their secrets."""
|
|
385
|
+
with command_errors():
|
|
386
|
+
entries = load_tokens(state_directory())
|
|
387
|
+
if not entries:
|
|
388
|
+
print("No subscription tokens.")
|
|
389
|
+
for item in entries:
|
|
390
|
+
print(f"{item.name}\t{item.domain}\t{item.filename}\t{item.created_at}")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@tokens.command("revoke", hidden=True)
|
|
394
|
+
def token_revoke(
|
|
395
|
+
name: Annotated[
|
|
396
|
+
str, typer.Argument(help="Name of the subscription token to revoke.")
|
|
397
|
+
],
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Revoke a token; the next request is denied without a service restart."""
|
|
400
|
+
with command_errors():
|
|
401
|
+
directory = state_directory()
|
|
402
|
+
with control_lock(directory):
|
|
403
|
+
revoke_token(directory, name)
|
|
404
|
+
print(f"Revoked subscription token: {name}")
|
|
405
|
+
|
|
406
|
+
|
|
273
407
|
@serve.command("stop")
|
|
274
408
|
def stop() -> None:
|
|
275
409
|
"""Stop this user's background configuration service."""
|
|
@@ -309,8 +309,19 @@ class ConfigRequestHandler(BaseHTTPRequestHandler):
|
|
|
309
309
|
server: ConfigHTTPServer
|
|
310
310
|
|
|
311
311
|
def do_GET(self) -> None:
|
|
312
|
+
from .serve_tokens import authorize_subscription
|
|
313
|
+
|
|
312
314
|
try:
|
|
313
315
|
domain = normalize_domain(self.headers.get("Host", ""))
|
|
316
|
+
parsed = urlsplit(self.path)
|
|
317
|
+
filename = unquote(parsed.path.removeprefix("/"))
|
|
318
|
+
authorize_subscription(
|
|
319
|
+
self.server.directory,
|
|
320
|
+
domain,
|
|
321
|
+
filename,
|
|
322
|
+
self.headers.get_all("Authorization", []),
|
|
323
|
+
parsed.query,
|
|
324
|
+
)
|
|
314
325
|
source = next(
|
|
315
326
|
(
|
|
316
327
|
item
|
|
@@ -319,7 +330,6 @@ class ConfigRequestHandler(BaseHTTPRequestHandler):
|
|
|
319
330
|
),
|
|
320
331
|
None,
|
|
321
332
|
)
|
|
322
|
-
filename = unquote(urlsplit(self.path).path.removeprefix("/"))
|
|
323
333
|
if source is None or not valid_filename(filename):
|
|
324
334
|
raise ServeError("Configuration not found.", 404)
|
|
325
335
|
content = self.server.github.config(source, filename)
|
|
@@ -336,6 +346,9 @@ class ConfigRequestHandler(BaseHTTPRequestHandler):
|
|
|
336
346
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
337
347
|
self.send_header("Content-Length", str(len(content)))
|
|
338
348
|
self.send_header("Cache-Control", "no-store")
|
|
349
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
350
|
+
if status == 401:
|
|
351
|
+
self.send_header("WWW-Authenticate", 'Bearer realm="subscription"')
|
|
339
352
|
self.end_headers()
|
|
340
353
|
try:
|
|
341
354
|
self.wfile.write(content)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Named, file-scoped subscription credentials; GitHub credentials stay separate."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import parse_qs
|
|
11
|
+
|
|
12
|
+
from .serve_backend import ServeError, normalize_domain, valid_filename, write_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class SubscriptionToken:
|
|
17
|
+
name: str
|
|
18
|
+
domain: str
|
|
19
|
+
filename: str
|
|
20
|
+
digest: str
|
|
21
|
+
created_at: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_tokens(directory: Path) -> list[SubscriptionToken]:
|
|
25
|
+
path = directory / "tokens.json"
|
|
26
|
+
try:
|
|
27
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
28
|
+
tokens = [SubscriptionToken(**item) for item in data["tokens"]]
|
|
29
|
+
if len({item.name for item in tokens}) != len(tokens):
|
|
30
|
+
raise ValueError
|
|
31
|
+
for item in tokens:
|
|
32
|
+
if (
|
|
33
|
+
not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", item.name)
|
|
34
|
+
or normalize_domain(item.domain) != item.domain
|
|
35
|
+
or not valid_filename(item.filename)
|
|
36
|
+
or not re.fullmatch(r"[0-9a-f]{64}", item.digest)
|
|
37
|
+
or not isinstance(item.created_at, str)
|
|
38
|
+
):
|
|
39
|
+
raise ValueError
|
|
40
|
+
return tokens
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
return []
|
|
43
|
+
except (OSError, ValueError, TypeError, KeyError, AttributeError, ServeError):
|
|
44
|
+
raise ServeError("Cannot read subscription token settings.", 503)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_token(directory: Path, name: str, domain: str, filename: str) -> str:
|
|
48
|
+
"""Return a new secret once and persist only its SHA-256 digest."""
|
|
49
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", name):
|
|
50
|
+
raise ServeError(
|
|
51
|
+
"Use a token name with 1-64 letters, digits, dots, _ or -.", 400
|
|
52
|
+
)
|
|
53
|
+
domain = normalize_domain(domain)
|
|
54
|
+
if not valid_filename(filename):
|
|
55
|
+
raise ServeError("Use a root configuration filename ending in .json.", 400)
|
|
56
|
+
tokens = load_tokens(directory)
|
|
57
|
+
if any(item.name == name for item in tokens):
|
|
58
|
+
raise ServeError(
|
|
59
|
+
"Token name already exists; revoke it before reusing the name.", 409
|
|
60
|
+
)
|
|
61
|
+
secret = secrets.token_urlsafe(32)
|
|
62
|
+
tokens.append(
|
|
63
|
+
SubscriptionToken(
|
|
64
|
+
name=name,
|
|
65
|
+
domain=domain,
|
|
66
|
+
filename=filename,
|
|
67
|
+
digest=hashlib.sha256(secret.encode()).hexdigest(),
|
|
68
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
write_json(directory / "tokens.json", {"tokens": [asdict(item) for item in tokens]})
|
|
72
|
+
return secret
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def revoke_token(directory: Path, name: str) -> None:
|
|
76
|
+
tokens = load_tokens(directory)
|
|
77
|
+
remaining = [item for item in tokens if item.name != name]
|
|
78
|
+
if len(remaining) == len(tokens):
|
|
79
|
+
raise ServeError("Subscription token not found.", 404)
|
|
80
|
+
write_json(
|
|
81
|
+
directory / "tokens.json", {"tokens": [asdict(item) for item in remaining]}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def revoke_subscription_tokens(
|
|
86
|
+
directory: Path, target: str, domain: str | None = None
|
|
87
|
+
) -> int:
|
|
88
|
+
"""Revoke an exact secret, or every token for a configuration filename."""
|
|
89
|
+
entries = load_tokens(directory)
|
|
90
|
+
if domain is not None:
|
|
91
|
+
domain = normalize_domain(domain)
|
|
92
|
+
if valid_filename(target):
|
|
93
|
+
matches = [
|
|
94
|
+
item
|
|
95
|
+
for item in entries
|
|
96
|
+
if item.filename == target and (domain is None or item.domain == domain)
|
|
97
|
+
]
|
|
98
|
+
if len({item.domain for item in matches}) > 1:
|
|
99
|
+
raise ServeError(
|
|
100
|
+
"This file has tokens on multiple domains; specify --domain.", 400
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
digest = hashlib.sha256(target.encode()).hexdigest()
|
|
104
|
+
matches = [
|
|
105
|
+
item
|
|
106
|
+
for item in entries
|
|
107
|
+
if secrets.compare_digest(item.digest, digest)
|
|
108
|
+
and (domain is None or item.domain == domain)
|
|
109
|
+
]
|
|
110
|
+
if not matches:
|
|
111
|
+
raise ServeError("No matching subscription tokens found.", 404)
|
|
112
|
+
remaining = [item for item in entries if item not in matches]
|
|
113
|
+
write_json(
|
|
114
|
+
directory / "tokens.json", {"tokens": [asdict(item) for item in remaining]}
|
|
115
|
+
)
|
|
116
|
+
return len(matches)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def authorize_subscription(
|
|
120
|
+
directory: Path, domain: str, filename: str, authorization: list[str], query: str
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Reject absent, conflicting, revoked or out-of-scope credentials."""
|
|
123
|
+
denied = ServeError("A valid subscription token is required.", 401)
|
|
124
|
+
try:
|
|
125
|
+
query_tokens = parse_qs(query, keep_blank_values=True, max_num_fields=32).get(
|
|
126
|
+
"token", []
|
|
127
|
+
)
|
|
128
|
+
except ValueError:
|
|
129
|
+
raise denied
|
|
130
|
+
if len(query_tokens) > 1 or len(authorization) > 1:
|
|
131
|
+
raise denied
|
|
132
|
+
bearer = None
|
|
133
|
+
if authorization:
|
|
134
|
+
parts = authorization[0].split()
|
|
135
|
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
136
|
+
raise denied
|
|
137
|
+
bearer = parts[1]
|
|
138
|
+
query_token = query_tokens[0] if query_tokens else None
|
|
139
|
+
if bearer is not None and query_token is not None and bearer != query_token:
|
|
140
|
+
raise denied
|
|
141
|
+
secret = bearer if bearer is not None else query_token
|
|
142
|
+
if not secret or len(secret) > 512 or any(char.isspace() for char in secret):
|
|
143
|
+
raise denied
|
|
144
|
+
digest = hashlib.sha256(secret.encode()).hexdigest()
|
|
145
|
+
if not any(
|
|
146
|
+
item.domain == domain
|
|
147
|
+
and item.filename == filename
|
|
148
|
+
and secrets.compare_digest(item.digest, digest)
|
|
149
|
+
for item in load_tokens(directory)
|
|
150
|
+
):
|
|
151
|
+
raise denied
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from difflib import unified_diff
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
6
|
+
from urllib.request import getproxies
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from rich import print
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def show_diff_config(current_config: str, new_config: str) -> None:
|
|
13
|
+
print("📄 Configuration differences:")
|
|
14
|
+
|
|
15
|
+
diff = list(
|
|
16
|
+
unified_diff(
|
|
17
|
+
current_config.splitlines(),
|
|
18
|
+
new_config.splitlines(),
|
|
19
|
+
fromfile="old",
|
|
20
|
+
tofile="new",
|
|
21
|
+
lineterm="",
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
for line in diff:
|
|
26
|
+
if line.startswith("+"):
|
|
27
|
+
print(f"[green]{line}[/green]")
|
|
28
|
+
elif line.startswith("-"):
|
|
29
|
+
print(f"[red]{line}[/red]")
|
|
30
|
+
elif line.startswith("@@"):
|
|
31
|
+
print(f"[blue]{line}[/blue]")
|
|
32
|
+
else:
|
|
33
|
+
print(f"[white]{line}[/white]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_json_asdict(config_file: Path) -> dict[str, Any]:
|
|
37
|
+
try:
|
|
38
|
+
return dict(json.loads(config_file.read_text(encoding="utf-8")))
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f"❌ Failed to load configuration: {e}")
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def subscription_credentials(
|
|
45
|
+
url: str, token: str | None = None
|
|
46
|
+
) -> tuple[str, str | None]:
|
|
47
|
+
"""Move a URL's subscription token out of the URL before logging or saving it."""
|
|
48
|
+
parsed = urlsplit(url)
|
|
49
|
+
pairs = parse_qsl(parsed.query, keep_blank_values=True, max_num_fields=32)
|
|
50
|
+
query_tokens = [value for key, value in pairs if key == "token"]
|
|
51
|
+
if not query_tokens:
|
|
52
|
+
return url, token
|
|
53
|
+
if (
|
|
54
|
+
len(query_tokens) != 1
|
|
55
|
+
or not query_tokens[0]
|
|
56
|
+
or len(query_tokens[0]) > 512
|
|
57
|
+
or any(char.isspace() for char in query_tokens[0])
|
|
58
|
+
):
|
|
59
|
+
raise ValueError("Use exactly one non-empty subscription token in the URL.")
|
|
60
|
+
url_token = query_tokens[0]
|
|
61
|
+
if token is not None and token != url_token:
|
|
62
|
+
raise ValueError("The URL token and --token differ; supply only one token.")
|
|
63
|
+
query = urlencode([(key, value) for key, value in pairs if key != "token"])
|
|
64
|
+
return urlunsplit(parsed._replace(query=query)), url_token
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def request_get(url: str, token: str) -> httpx.Response | None:
|
|
68
|
+
# Strip credentials before any error can include the request URL.
|
|
69
|
+
safe_url = "subscription URL"
|
|
70
|
+
try:
|
|
71
|
+
url, resolved_token = subscription_credentials(url, token or None)
|
|
72
|
+
safe_url = url
|
|
73
|
+
headers = {"Content-Type": "application/json"}
|
|
74
|
+
if resolved_token:
|
|
75
|
+
headers["Authorization"] = f"Bearer {resolved_token}"
|
|
76
|
+
try:
|
|
77
|
+
response = httpx.get(url, headers=headers)
|
|
78
|
+
except (httpx.ConnectError, httpx.ConnectTimeout):
|
|
79
|
+
# A custom transport disables HTTPX's environment proxy detection.
|
|
80
|
+
# Never bypass a configured proxy when retrying a failed connection.
|
|
81
|
+
proxies = getproxies()
|
|
82
|
+
if any(proxies.get(scheme) for scheme in ("http", "https", "all")):
|
|
83
|
+
raise
|
|
84
|
+
print("⚠️ Connection failed; retrying over IPv4.")
|
|
85
|
+
transport = httpx.HTTPTransport(local_address="0.0.0.0")
|
|
86
|
+
with httpx.Client(transport=transport) as client:
|
|
87
|
+
response = client.get(url, headers=headers)
|
|
88
|
+
response.raise_for_status()
|
|
89
|
+
return response
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print(f"❌ Failed to get from {safe_url}: {e}")
|
|
92
|
+
return None
|
|
@@ -9,6 +9,7 @@ from sing_box_cli.api import api as api_app
|
|
|
9
9
|
from sing_box_cli.common import ClearCacheOption, UpdateConfigOption, ensure_root
|
|
10
10
|
from sing_box_cli.config import config as config_app
|
|
11
11
|
from sing_box_cli.config.config import get_config, run_args
|
|
12
|
+
from sing_box_cli.config.serve import tokens as token_app
|
|
12
13
|
from sing_box_cli.installation import install_launchers
|
|
13
14
|
from sing_box_cli.service import SharedContext, get_context_obj, service as service_app
|
|
14
15
|
from sing_box_cli.service.manager import create_service
|
|
@@ -17,11 +18,12 @@ app = typer.Typer(help="sing-box manager.")
|
|
|
17
18
|
app.add_typer(api_app)
|
|
18
19
|
app.add_typer(service_app, name="service")
|
|
19
20
|
app.add_typer(config_app, name="config")
|
|
21
|
+
app.add_typer(token_app, name="token")
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
@app.callback(invoke_without_command=False)
|
|
23
25
|
def callback(ctx: typer.Context) -> None:
|
|
24
|
-
if ctx.invoked_subcommand in ("install", "config"):
|
|
26
|
+
if ctx.invoked_subcommand in ("install", "config", "token"):
|
|
25
27
|
return
|
|
26
28
|
cfg = get_config()
|
|
27
29
|
service = create_service(cfg)
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# Configuration server
|
|
2
|
-
|
|
3
|
-
The server maps one domain to one private GitHub repository. Multiple domains can
|
|
4
|
-
share a server. Set `GH_TOKEN` or `GITHUB_TOKEN` to a fine-grained token with
|
|
5
|
-
**Contents: read** access to all registered repositories.
|
|
6
|
-
|
|
7
|
-
`config serve add` checks GitHub access and saves the binding; registering the same
|
|
8
|
-
binding again is safe. `config serve list` validates root `.json` files with the
|
|
9
|
-
sing-box core bundled with this package, prints valid URLs, and reports invalid
|
|
10
|
-
configurations on stderr.
|
|
11
|
-
|
|
12
|
-
## Background process
|
|
13
|
-
|
|
14
|
-
`config serve start` waits until the socket is listening, then prints the PID,
|
|
15
|
-
address and log path. Closing the terminal leaves the server running. Repeated
|
|
16
|
-
starts use the existing process, and repeated stops are safe. Run `stop` as the
|
|
17
|
-
same user with the same state directory. Restart after changing the GitHub token
|
|
18
|
-
or upgrading the package. Boot-time startup is managed separately by deployment.
|
|
19
|
-
|
|
20
|
-
Settings, process identity and `serve.log` are stored in the platform's
|
|
21
|
-
`sing-box-cli/serve` application directory (`~/.config/sing-box-cli/serve` on Linux).
|
|
22
|
-
Set `SBC_SERVE_DIR` to use another directory for all commands. Tokens and client
|
|
23
|
-
configuration are not stored there. New registrations take effect while running.
|
|
24
|
-
|
|
25
|
-
## GitHub reads and validation
|
|
26
|
-
|
|
27
|
-
Requests read the repository's default branch through the GitHub Contents API.
|
|
28
|
-
No Git installation, clone/pull, background synchronization or persistent config
|
|
29
|
-
cache is needed. File updates and deletions take effect on subsequent requests.
|
|
30
|
-
|
|
31
|
-
Each response is validated and preserves the original content. Missing files,
|
|
32
|
-
invalid configurations and GitHub errors return HTTP errors. Configurations are
|
|
33
|
-
limited to 5 MiB and must be compatible with the bundled core. Referenced sibling
|
|
34
|
-
files are not downloaded. Existing client `--token` subscriptions and saved
|
|
35
|
-
settings remain supported; server URLs need no client token.
|
|
36
|
-
|
|
37
|
-
Android's `route.override_android_vpn: true` is disabled only in the temporary
|
|
38
|
-
core-check copy, so a Linux or Windows server can distribute Android configurations.
|
|
39
|
-
Responses retain the original bytes, including the Android setting. Invalid
|
|
40
|
-
option types, unknown fields and other core errors are still rejected; this does
|
|
41
|
-
not verify the target device's runtime environment.
|
|
42
|
-
|
|
43
|
-
## Traefik
|
|
44
|
-
|
|
45
|
-
Merge [the example router and service](traefik-config-serve.yml) into the existing
|
|
46
|
-
`http.routers` and `http.services` sections. Traefik must preserve the `Host` header.
|
|
47
|
-
The example uses `websecure` and the host gateway from `digitalocean-public`'s
|
|
48
|
-
`~/devspace/traefik.yml`. Bind to that gateway for this deployment:
|
|
49
|
-
|
|
50
|
-
```bash
|
|
51
|
-
sbc config serve start --host 172.18.0.1 --port 8080
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
Replace `sub.example.com` with your domain and configure DNS and the TLS
|
|
55
|
-
certificate through your deployment. `--domain` only registers the binding.
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
from typing import Annotated
|
|
2
|
-
|
|
3
|
-
import typer
|
|
4
|
-
from rich import print
|
|
5
|
-
|
|
6
|
-
from ..common import StrOrNone, ensure_root
|
|
7
|
-
from ..service import SharedContext, get_context_obj
|
|
8
|
-
from ..service.manager import create_service
|
|
9
|
-
from .config import get_config
|
|
10
|
-
from .serve import serve
|
|
11
|
-
|
|
12
|
-
__all__ = ["config"]
|
|
13
|
-
|
|
14
|
-
SubUrlArg = Annotated[str, typer.Argument(help="Subscription URL")]
|
|
15
|
-
TokenOption = Annotated[
|
|
16
|
-
StrOrNone,
|
|
17
|
-
typer.Option("--token", "-t", help="Authentication token for the subscription URL"),
|
|
18
|
-
]
|
|
19
|
-
RestartServiceOption = Annotated[
|
|
20
|
-
bool, typer.Option("--restart", "-r", help="Restart service after update.")
|
|
21
|
-
]
|
|
22
|
-
config = typer.Typer(help="Configuration management commands")
|
|
23
|
-
config.add_typer(serve, name="serve")
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@config.callback()
|
|
27
|
-
def config_callback(ctx: typer.Context) -> None:
|
|
28
|
-
if ctx.invoked_subcommand != "serve":
|
|
29
|
-
cfg = get_config()
|
|
30
|
-
ctx.obj = SharedContext(config=cfg, service=create_service(cfg))
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@config.command("update")
|
|
34
|
-
def config_update(
|
|
35
|
-
ctx: typer.Context,
|
|
36
|
-
url: SubUrlArg,
|
|
37
|
-
token: TokenOption = None,
|
|
38
|
-
restart: RestartServiceOption = False,
|
|
39
|
-
) -> None:
|
|
40
|
-
"""download configuration, save subscription url and restart service if needed"""
|
|
41
|
-
ctx_obj = get_context_obj(ctx)
|
|
42
|
-
if ctx_obj.config.update_config(url, token):
|
|
43
|
-
pass
|
|
44
|
-
else:
|
|
45
|
-
print("❌ Failed to update configuration.")
|
|
46
|
-
raise typer.Exit(1)
|
|
47
|
-
if restart:
|
|
48
|
-
ensure_root()
|
|
49
|
-
# init service
|
|
50
|
-
if not ctx_obj.service.check_service():
|
|
51
|
-
ctx_obj.service.create_service()
|
|
52
|
-
print("⌛ Service created successfully.")
|
|
53
|
-
ctx_obj.service.restart()
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
@config.command("show-sub")
|
|
57
|
-
def config_show_sub(ctx: typer.Context) -> None:
|
|
58
|
-
"""Show subscription URL"""
|
|
59
|
-
ctx_obj = get_context_obj(ctx)
|
|
60
|
-
sub_url = ctx_obj.config.sub_url
|
|
61
|
-
if sub_url:
|
|
62
|
-
print(f"🔗 Current subscription URL: {sub_url}")
|
|
63
|
-
else:
|
|
64
|
-
print("❌ No subscription URL found.")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
@config.command("show")
|
|
68
|
-
def config_show(ctx: typer.Context) -> None:
|
|
69
|
-
"""Show configuration file"""
|
|
70
|
-
ctx_obj = get_context_obj(ctx)
|
|
71
|
-
print(ctx_obj.config.config_file_content)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@config.command("clear_cache")
|
|
75
|
-
def config_clear_cache(ctx: typer.Context) -> None:
|
|
76
|
-
"""Clean cache database"""
|
|
77
|
-
ctx_obj = get_context_obj(ctx)
|
|
78
|
-
ctx_obj.config.clear_cache()
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from difflib import unified_diff
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
from typing import Any
|
|
5
|
-
|
|
6
|
-
import httpx
|
|
7
|
-
from rich import print
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def show_diff_config(current_config: str, new_config: str) -> None:
|
|
11
|
-
print("📄 Configuration differences:")
|
|
12
|
-
|
|
13
|
-
diff = list(
|
|
14
|
-
unified_diff(
|
|
15
|
-
current_config.splitlines(),
|
|
16
|
-
new_config.splitlines(),
|
|
17
|
-
fromfile="old",
|
|
18
|
-
tofile="new",
|
|
19
|
-
lineterm="",
|
|
20
|
-
)
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
for line in diff:
|
|
24
|
-
if line.startswith("+"):
|
|
25
|
-
print(f"[green]{line}[/green]")
|
|
26
|
-
elif line.startswith("-"):
|
|
27
|
-
print(f"[red]{line}[/red]")
|
|
28
|
-
elif line.startswith("@@"):
|
|
29
|
-
print(f"[blue]{line}[/blue]")
|
|
30
|
-
else:
|
|
31
|
-
print(f"[white]{line}[/white]")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def load_json_asdict(config_file: Path) -> dict[str, Any]:
|
|
35
|
-
try:
|
|
36
|
-
return dict(json.loads(config_file.read_text(encoding="utf-8")))
|
|
37
|
-
except Exception as e:
|
|
38
|
-
print(f"❌ Failed to load configuration: {e}")
|
|
39
|
-
return {}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def request_get(url: str, token: str) -> httpx.Response | None:
|
|
43
|
-
headers = {"Content-Type": "application/json"}
|
|
44
|
-
if token:
|
|
45
|
-
headers["Authorization"] = f"Bearer {token}"
|
|
46
|
-
try:
|
|
47
|
-
response = httpx.get(url, headers=headers)
|
|
48
|
-
response.raise_for_status()
|
|
49
|
-
return response
|
|
50
|
-
except Exception as e:
|
|
51
|
-
print(f"❌ Failed to get from {url}: {e}")
|
|
52
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|