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.
Files changed (40) hide show
  1. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/PKG-INFO +39 -2
  2. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/README.md +38 -1
  3. sing_box_cli-0.1.6/docs/config-serve.md +108 -0
  4. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/pyproject.toml +1 -1
  5. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/__init__.py +1 -1
  6. sing_box_cli-0.1.6/src/sing_box_cli/config/__init__.py +97 -0
  7. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/config/config.py +25 -11
  8. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/config/serve.py +134 -0
  9. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/config/serve_backend.py +14 -1
  10. sing_box_cli-0.1.6/src/sing_box_cli/config/serve_tokens.py +151 -0
  11. sing_box_cli-0.1.6/src/sing_box_cli/config/utils.py +92 -0
  12. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/main.py +3 -1
  13. sing_box_cli-0.1.4/docs/config-serve.md +0 -55
  14. sing_box_cli-0.1.4/src/sing_box_cli/config/__init__.py +0 -78
  15. sing_box_cli-0.1.4/src/sing_box_cli/config/utils.py +0 -52
  16. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/.gitignore +0 -0
  17. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/LICENSE +0 -0
  18. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/docs/traefik-config-serve.yml +0 -0
  19. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/hatch_build.py +0 -0
  20. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/__init__.py +0 -0
  21. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/client.py +0 -0
  22. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/connections.py +0 -0
  23. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/logs.py +0 -0
  24. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/monitor.py +0 -0
  25. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/policy.py +0 -0
  26. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/api/widget.py +0 -0
  27. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/common.py +0 -0
  28. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/config/_serve_worker.py +0 -0
  29. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/installation.py +0 -0
  30. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/py.typed +0 -0
  31. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/service/__init__.py +0 -0
  32. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/src/sing_box_cli/service/manager.py +0 -0
  33. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/common.go +0 -0
  34. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/common_test.go +0 -0
  35. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/go.mod +0 -0
  36. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/go.sum +0 -0
  37. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/integration_windows_test.go +0 -0
  38. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/main_other.go +0 -0
  39. {sing_box_cli-0.1.4 → sing_box_cli-0.1.6}/windows-service/main_windows.go +0 -0
  40. {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.4
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.4"
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}"
@@ -2,4 +2,4 @@ from .main import main
2
2
 
3
3
  __all__ = ["main"]
4
4
 
5
- __version__ = "0.1.4"
5
+ __version__ = "0.1.6"
@@ -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(self, sub_url: StrOrNone = None, token: StrOrNone = None) -> bool:
243
- """Download configuration from subscription URL and show differences."""
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
- print(f"⌛ Updating configuration from {sub_url}")
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
- # update and show differences
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
- """Get a cached ConfigHandler instance."""
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