alle-proxy 0.1.0__py3-none-any.whl
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.
- alle/__init__.py +8 -0
- alle/__main__.py +4 -0
- alle/applog.py +73 -0
- alle/cli.py +499 -0
- alle/constants.py +28 -0
- alle/credentials.py +84 -0
- alle/daemon.py +193 -0
- alle/engine.py +153 -0
- alle/geo.py +45 -0
- alle/locations.py +93 -0
- alle/metrics.py +152 -0
- alle/output.py +305 -0
- alle/paths.py +16 -0
- alle/probe.py +139 -0
- alle/providers.py +301 -0
- alle/reconnect.py +161 -0
- alle/service.py +554 -0
- alle/singbox.py +361 -0
- alle/state.py +387 -0
- alle/throughput.py +170 -0
- alle/wgconf.py +173 -0
- alle_proxy-0.1.0.dist-info/METADATA +286 -0
- alle_proxy-0.1.0.dist-info/RECORD +26 -0
- alle_proxy-0.1.0.dist-info/WHEEL +4 -0
- alle_proxy-0.1.0.dist-info/entry_points.txt +3 -0
- alle_proxy-0.1.0.dist-info/licenses/LICENSE +21 -0
alle/__init__.py
ADDED
alle/__main__.py
ADDED
alle/applog.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""alle's own operation log: ``~/.alle/alle.log``.
|
|
2
|
+
|
|
3
|
+
This is *alle's* record of what it did — providers/channels added or removed,
|
|
4
|
+
heartbeat probes, reconciles, sing-box binary downloads, ``up``/``down`` — not
|
|
5
|
+
sing-box's logging (that lives in ``singbox.log`` and surfaces elsewhere). Both
|
|
6
|
+
the CLI and the applier daemon append here, so a user running ``alle logs -f``
|
|
7
|
+
sees a single timeline of everything happening.
|
|
8
|
+
|
|
9
|
+
Appends are line-oriented and O_APPEND, which is atomic for the short lines we
|
|
10
|
+
write, so concurrent writers from different processes don't interleave.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from alle import paths
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _log_path() -> Path:
|
|
23
|
+
return paths.state_dir() / "alle.log"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def log(message: str) -> None:
|
|
27
|
+
"""Append one timestamped line. Best-effort: never raises into the caller."""
|
|
28
|
+
line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {message}\n"
|
|
29
|
+
try:
|
|
30
|
+
with open(_log_path(), "a") as f:
|
|
31
|
+
f.write(line)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def tail(n: int = 200) -> str:
|
|
37
|
+
p = _log_path()
|
|
38
|
+
if not p.exists():
|
|
39
|
+
return "(no logs yet)"
|
|
40
|
+
lines = p.read_text(errors="replace").splitlines()
|
|
41
|
+
return "\n".join(lines[-n:]) or "(no logs yet)"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def follow(n: int = 50) -> None:
|
|
45
|
+
"""Print the last ``n`` lines then stream new ones (``alle logs -f``).
|
|
46
|
+
|
|
47
|
+
Blocks until interrupted. Re-opens the file if it is rotated/recreated.
|
|
48
|
+
"""
|
|
49
|
+
p = _log_path()
|
|
50
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
p.touch(exist_ok=True)
|
|
52
|
+
# seed with the tail
|
|
53
|
+
existing = p.read_text(errors="replace").splitlines()
|
|
54
|
+
for ln in existing[-n:]:
|
|
55
|
+
print(ln, flush=True)
|
|
56
|
+
f = open(p)
|
|
57
|
+
try:
|
|
58
|
+
f.seek(0, os.SEEK_END)
|
|
59
|
+
while True:
|
|
60
|
+
line = f.readline()
|
|
61
|
+
if line:
|
|
62
|
+
print(line.rstrip("\n"), flush=True)
|
|
63
|
+
continue
|
|
64
|
+
# nothing new; detect truncation/rotation then wait briefly
|
|
65
|
+
time.sleep(0.4)
|
|
66
|
+
try:
|
|
67
|
+
if p.stat().st_size < f.tell():
|
|
68
|
+
f.close()
|
|
69
|
+
f = open(p)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
finally:
|
|
73
|
+
f.close()
|
alle/cli.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""alle command-line interface.
|
|
2
|
+
|
|
3
|
+
The data model is provider-centric: **each provider owns a list of channels**.
|
|
4
|
+
You add a provider once (``alle providers add <name>``), then add channels
|
|
5
|
+
under it (``alle channels add <name> --country …``). All of it lives in one
|
|
6
|
+
``~/.alle/state.json``; provider tokens live in ``credentials.yaml``.
|
|
7
|
+
|
|
8
|
+
The CLI only adapts terminal input/output to the shared application layer. A
|
|
9
|
+
detached applier daemon watches the state file, makes the single sing-box
|
|
10
|
+
process match it, and heartbeat-probes every channel — so adding or removing a
|
|
11
|
+
channel is enough; there is no separate "apply" step. WireGuard is
|
|
12
|
+
connectionless, so there is no enable/disable: a channel is "active" only if its
|
|
13
|
+
latest probe succeeded.
|
|
14
|
+
|
|
15
|
+
Read commands accept ``--json`` for **shell / cross-language scripting** (jq,
|
|
16
|
+
monitoring hooks, CI): it is a direct serialization of the ``alle.service`` return
|
|
17
|
+
value, not a scrape of the human text. It is deliberately *not* the programmatic
|
|
18
|
+
interface for alle's own components — the Web UI, desktop companion, and any typed
|
|
19
|
+
client use ``alle.service`` (and later the ``alled`` control API) directly rather
|
|
20
|
+
than shelling out to the CLI and parsing its output.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import getpass
|
|
27
|
+
import itertools
|
|
28
|
+
import sys
|
|
29
|
+
import threading
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
from alle import __version__, applog, daemon, output, service
|
|
33
|
+
from alle.providers import (
|
|
34
|
+
ProviderError,
|
|
35
|
+
auth_fields,
|
|
36
|
+
auth_help,
|
|
37
|
+
display_name,
|
|
38
|
+
kind,
|
|
39
|
+
known,
|
|
40
|
+
match,
|
|
41
|
+
)
|
|
42
|
+
from alle.state import Store
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ---- secret entry ----------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_secret_chars(read_char, echo) -> str:
|
|
49
|
+
"""Assemble a secret from single characters, masking echo and handling paste."""
|
|
50
|
+
buf: list[str] = []
|
|
51
|
+
while True:
|
|
52
|
+
ch = read_char()
|
|
53
|
+
if ch in ("\r", "\n", ""): # Enter or EOF ends input
|
|
54
|
+
break
|
|
55
|
+
if ch == "\x03": # Ctrl-C
|
|
56
|
+
raise KeyboardInterrupt
|
|
57
|
+
if ch in ("\x7f", "\x08"): # backspace
|
|
58
|
+
if buf:
|
|
59
|
+
buf.pop()
|
|
60
|
+
echo("\b \b")
|
|
61
|
+
continue
|
|
62
|
+
if ch == "\x1b": # escape sequence (bracketed paste, arrow keys, ...)
|
|
63
|
+
nxt = read_char()
|
|
64
|
+
if nxt == "[": # CSI: consume through its final byte
|
|
65
|
+
while True:
|
|
66
|
+
c = read_char()
|
|
67
|
+
if c == "" or c.isalpha() or c == "~":
|
|
68
|
+
break
|
|
69
|
+
elif nxt == "O": # SS3 (F1–F4, arrows in application mode): one final byte
|
|
70
|
+
read_char()
|
|
71
|
+
continue
|
|
72
|
+
buf.append(ch)
|
|
73
|
+
echo("*")
|
|
74
|
+
return "".join(buf)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _read_secret(prompt: str) -> str:
|
|
78
|
+
"""Read a secret from the terminal, echoing one ``*`` per character.
|
|
79
|
+
|
|
80
|
+
Gives visible feedback (unlike getpass) and reads in cbreak mode so a paste
|
|
81
|
+
arrives intact. Falls back to getpass when stdin isn't a real TTY.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
import termios
|
|
85
|
+
import tty
|
|
86
|
+
except ImportError: # non-Unix
|
|
87
|
+
return getpass.getpass(prompt)
|
|
88
|
+
if not sys.stdin.isatty():
|
|
89
|
+
return getpass.getpass(prompt)
|
|
90
|
+
|
|
91
|
+
def echo(s):
|
|
92
|
+
sys.stdout.write(s)
|
|
93
|
+
sys.stdout.flush()
|
|
94
|
+
|
|
95
|
+
sys.stdout.write(prompt)
|
|
96
|
+
sys.stdout.flush()
|
|
97
|
+
fd = sys.stdin.fileno()
|
|
98
|
+
old = termios.tcgetattr(fd)
|
|
99
|
+
try:
|
|
100
|
+
tty.setcbreak(fd)
|
|
101
|
+
return _read_secret_chars(lambda: sys.stdin.read(1), echo)
|
|
102
|
+
finally:
|
|
103
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
104
|
+
sys.stdout.write("\n")
|
|
105
|
+
sys.stdout.flush()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- helpers ---------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _resolve_provider(name: str) -> str:
|
|
112
|
+
return service.resolve_provider(name)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _print_or_json(data: dict, render, json_: bool) -> None:
|
|
116
|
+
print(output.json_text(data) if json_ else render(data))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---- providers -------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_providers_add(args):
|
|
123
|
+
provider = _resolve_provider(args.provider)
|
|
124
|
+
|
|
125
|
+
if kind(provider) == "config":
|
|
126
|
+
result = service.provider_add_config(provider)
|
|
127
|
+
print(f"Added provider {result['display_name']}.")
|
|
128
|
+
help_ = result["config_help"]
|
|
129
|
+
if help_:
|
|
130
|
+
print(f" {help_}")
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# token provider — prompt, (validate if functional), store credential
|
|
134
|
+
help_text, url = auth_help(provider)
|
|
135
|
+
print(f"Add provider {display_name(provider)}.")
|
|
136
|
+
if help_text:
|
|
137
|
+
print(f" How to obtain: {help_text}")
|
|
138
|
+
if url:
|
|
139
|
+
print(f" {url}")
|
|
140
|
+
creds = {}
|
|
141
|
+
for field in auth_fields(provider):
|
|
142
|
+
prompt = f" {field.label}: "
|
|
143
|
+
value = (_read_secret(prompt) if field.secret else input(prompt)).strip()
|
|
144
|
+
if not value:
|
|
145
|
+
sys.exit(f"{field.label} is required.")
|
|
146
|
+
creds[field.key] = value
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
result = service.provider_add_token(provider, creds)
|
|
150
|
+
except ProviderError as e:
|
|
151
|
+
sys.exit(f"credential rejected: {e}")
|
|
152
|
+
|
|
153
|
+
if not result["functional"]:
|
|
154
|
+
print(
|
|
155
|
+
f" Note: adding channels under {display_name(provider)} isn't implemented "
|
|
156
|
+
"yet — credential stored for when it is."
|
|
157
|
+
)
|
|
158
|
+
print(
|
|
159
|
+
f"Added provider {result['display_name']} (credential {result['credential_preview']})."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_providers_ls(args):
|
|
164
|
+
_print_or_json(service.provider_list(), output.providers_list, args.json)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cmd_providers_rm(args):
|
|
168
|
+
provider = _resolve_provider(args.provider)
|
|
169
|
+
store = Store.load()
|
|
170
|
+
if not store.has_provider(provider):
|
|
171
|
+
sys.exit(f"{display_name(provider)} is not added.")
|
|
172
|
+
n = len(store.provider_channels(provider))
|
|
173
|
+
if not args.yes:
|
|
174
|
+
print(
|
|
175
|
+
f"WARNING: removing {display_name(provider)} will delete its "
|
|
176
|
+
f"{n} channel(s) AND its stored credential. You will need to re-add the "
|
|
177
|
+
"provider (and re-enter the token) to use it again."
|
|
178
|
+
)
|
|
179
|
+
# Accept the key ("protonvpn") or the display name just shown ("Proton VPN").
|
|
180
|
+
if match(input("Type the provider name to confirm: ")) != provider:
|
|
181
|
+
sys.exit("Aborted.")
|
|
182
|
+
result = service.provider_remove(provider)
|
|
183
|
+
print(
|
|
184
|
+
f"Removed {result['display_name']} and its {result['channels_removed']} channel(s)."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---- channels --------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_channels_add(args):
|
|
192
|
+
provider = _resolve_provider(args.provider)
|
|
193
|
+
result = service.channel_add(provider, args.country, args.city, args.config)
|
|
194
|
+
channel = result["channel"]
|
|
195
|
+
if result.get("imported_from"):
|
|
196
|
+
verb = "Updated" if result.get("updated") else "Imported"
|
|
197
|
+
source = f" from {result['imported_from']}"
|
|
198
|
+
else:
|
|
199
|
+
verb, source = "Added", ""
|
|
200
|
+
print(
|
|
201
|
+
f"{verb} channel {channel.id} under {result['display_name']}{source} "
|
|
202
|
+
f"on 127.0.0.1:{channel.port}."
|
|
203
|
+
)
|
|
204
|
+
print("Applying… (see: alle status)")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def cmd_channels_ls(args):
|
|
208
|
+
"""List configured channels grouped by provider — static config only, no
|
|
209
|
+
connection status and independent of whether alle is up or down."""
|
|
210
|
+
_print_or_json(service.channel_list(), output.channels_list, args.json)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_channels_rm(args):
|
|
214
|
+
provider = _resolve_provider(args.provider)
|
|
215
|
+
result = service.channel_remove(provider, args.channel)
|
|
216
|
+
print(f"Removed channel {result['channel']} from {result['display_name']}.")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---- locations -------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def cmd_locations(args):
|
|
223
|
+
provider = _resolve_provider(args.provider)
|
|
224
|
+
_print_or_json(
|
|
225
|
+
service.locations_list(provider, args.country, args.refresh),
|
|
226
|
+
output.locations,
|
|
227
|
+
args.json,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ---- status / test ---------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def cmd_status(args):
|
|
235
|
+
_print_or_json(service.status_snapshot(), output.status, args.json)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def cmd_test(args):
|
|
239
|
+
spinner = _Spinner("testing…") if args.speed and not args.json else None
|
|
240
|
+
|
|
241
|
+
def progress(row, phase):
|
|
242
|
+
if spinner is not None:
|
|
243
|
+
spinner.label = f"{row['provider']}/{row['name']} {phase}…"
|
|
244
|
+
|
|
245
|
+
if spinner is not None:
|
|
246
|
+
with spinner:
|
|
247
|
+
result = service.test(
|
|
248
|
+
speed=args.speed, channel=args.channel, progress=progress
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
result = service.test(speed=args.speed, channel=args.channel)
|
|
252
|
+
|
|
253
|
+
if args.json:
|
|
254
|
+
print(output.json_text(result))
|
|
255
|
+
return
|
|
256
|
+
print(output.test_result(result))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_metrics(args):
|
|
260
|
+
_print_or_json(service.metrics_snapshot(args.channel), output.metrics, args.json)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class _Spinner:
|
|
264
|
+
"""A tiny stderr spinner for long, one-at-a-time work (the speed test).
|
|
265
|
+
|
|
266
|
+
Animates only on a real TTY; when stderr is redirected (pipe, CI) it stays
|
|
267
|
+
silent so captured output isn't polluted. ``label`` may be updated live to
|
|
268
|
+
reflect the current phase.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
272
|
+
|
|
273
|
+
def __init__(self, label: str):
|
|
274
|
+
self.label = label
|
|
275
|
+
self._stop = threading.Event()
|
|
276
|
+
self._thread: threading.Thread | None = None
|
|
277
|
+
|
|
278
|
+
def __enter__(self):
|
|
279
|
+
if sys.stderr.isatty():
|
|
280
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
281
|
+
self._thread.start()
|
|
282
|
+
return self
|
|
283
|
+
|
|
284
|
+
def _spin(self):
|
|
285
|
+
for frame in itertools.cycle(self._FRAMES):
|
|
286
|
+
if self._stop.is_set():
|
|
287
|
+
break
|
|
288
|
+
sys.stderr.write(f"\r {frame} {self.label}\033[K")
|
|
289
|
+
sys.stderr.flush()
|
|
290
|
+
time.sleep(0.1)
|
|
291
|
+
|
|
292
|
+
def __exit__(self, *exc):
|
|
293
|
+
self._stop.set()
|
|
294
|
+
if self._thread:
|
|
295
|
+
self._thread.join()
|
|
296
|
+
if sys.stderr.isatty():
|
|
297
|
+
sys.stderr.write("\r\033[K") # wipe the spinner line
|
|
298
|
+
sys.stderr.flush()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ---- start / stop / restart ------------------------------------------------
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def cmd_start(args):
|
|
305
|
+
result = service.start()
|
|
306
|
+
if result["has_channels"]:
|
|
307
|
+
print("Alle started; channels are being applied and probed. See: alle status")
|
|
308
|
+
else:
|
|
309
|
+
print(
|
|
310
|
+
"Alle started (sing-box running idle — no channels yet). "
|
|
311
|
+
"Add one: alle channels add <provider> --country …"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def cmd_stop(args):
|
|
316
|
+
result = service.stop()
|
|
317
|
+
if result["was_running"]:
|
|
318
|
+
print("Alle stopped (channels kept in config).")
|
|
319
|
+
else:
|
|
320
|
+
print("Alle is already stopped.")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def cmd_restart(args):
|
|
324
|
+
service.restart()
|
|
325
|
+
print("Alle restarted. See: alle status")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---- logs ------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def cmd_logs(args):
|
|
332
|
+
if args.follow:
|
|
333
|
+
applog.follow(args.lines)
|
|
334
|
+
else:
|
|
335
|
+
print(service.logs_tail(args.lines))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---- version ---------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def cmd_version(args):
|
|
342
|
+
print(__version__)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ---- applier (hidden) ------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def cmd_applier(args):
|
|
349
|
+
daemon.run_applier()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---- parser ----------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class _HelpOnErrorParser(argparse.ArgumentParser):
|
|
356
|
+
"""ArgumentParser that prints help instead of a terse error when arguments
|
|
357
|
+
are missing or invalid. ``add_subparsers``/``add_parser`` propagate this class
|
|
358
|
+
to every level, so e.g. ``alle providers add`` (missing ``provider``)
|
|
359
|
+
shows the same help as ``alle providers add -h``."""
|
|
360
|
+
|
|
361
|
+
def error(self, message):
|
|
362
|
+
self.print_help()
|
|
363
|
+
self.exit(2)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _show_help(parser: argparse.ArgumentParser):
|
|
367
|
+
"""A `func` that prints a parser's help — used when a (sub)command is given
|
|
368
|
+
with no action, so the user sees usage instead of an argparse error."""
|
|
369
|
+
|
|
370
|
+
def run(_args):
|
|
371
|
+
parser.print_help()
|
|
372
|
+
|
|
373
|
+
return run
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _provider_help() -> str:
|
|
377
|
+
"""Enumerate the supported providers for argument help."""
|
|
378
|
+
return "supported provider; one of: " + ", ".join(known())
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
382
|
+
p = _HelpOnErrorParser(prog="alle", description=__doc__)
|
|
383
|
+
p.set_defaults(func=_show_help(p))
|
|
384
|
+
sub = p.add_subparsers(dest="command")
|
|
385
|
+
|
|
386
|
+
# providers
|
|
387
|
+
pr = sub.add_parser("providers", help="manage VPN providers")
|
|
388
|
+
pr.set_defaults(func=_show_help(pr))
|
|
389
|
+
pr_sub = pr.add_subparsers(dest="providers_command")
|
|
390
|
+
pa = pr_sub.add_parser("add", help="add a provider (prompts for a token if needed)")
|
|
391
|
+
pa.add_argument("provider", help=_provider_help())
|
|
392
|
+
pa.set_defaults(func=cmd_providers_add)
|
|
393
|
+
pls = pr_sub.add_parser("ls", help="list the providers you've added")
|
|
394
|
+
pls.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
395
|
+
pls.set_defaults(func=cmd_providers_ls)
|
|
396
|
+
pd = pr_sub.add_parser(
|
|
397
|
+
"rm", help="remove a provider AND all its channels + credential"
|
|
398
|
+
)
|
|
399
|
+
pd.add_argument("provider", help=_provider_help())
|
|
400
|
+
pd.add_argument(
|
|
401
|
+
"-y", "--yes", action="store_true", help="skip the confirmation prompt"
|
|
402
|
+
)
|
|
403
|
+
pd.set_defaults(func=cmd_providers_rm)
|
|
404
|
+
|
|
405
|
+
# channels
|
|
406
|
+
ch = sub.add_parser("channels", help="manage channels under a provider")
|
|
407
|
+
ch.set_defaults(func=_show_help(ch))
|
|
408
|
+
ch_sub = ch.add_subparsers(dest="channels_command")
|
|
409
|
+
ca = ch_sub.add_parser("add", help="add a channel under a provider")
|
|
410
|
+
ca.add_argument("provider", help=_provider_help())
|
|
411
|
+
ca.add_argument("--country", help="country — API providers only (e.g. nordvpn)")
|
|
412
|
+
ca.add_argument("--city", help="city — API providers only (omit = any city)")
|
|
413
|
+
ca.add_argument(
|
|
414
|
+
"--config",
|
|
415
|
+
help="path to a WireGuard .conf — config providers only (e.g. protonvpn); "
|
|
416
|
+
"mutually exclusive with --country/--city",
|
|
417
|
+
)
|
|
418
|
+
ca.set_defaults(func=cmd_channels_add)
|
|
419
|
+
cls = ch_sub.add_parser(
|
|
420
|
+
"ls", help="list configured channels by provider (no status)"
|
|
421
|
+
)
|
|
422
|
+
cls.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
423
|
+
cls.set_defaults(func=cmd_channels_ls)
|
|
424
|
+
cr = ch_sub.add_parser("rm", help="remove a channel from a provider")
|
|
425
|
+
cr.add_argument("provider", help=_provider_help())
|
|
426
|
+
cr.add_argument("--channel", required=True, help="channel name to remove")
|
|
427
|
+
cr.set_defaults(func=cmd_channels_rm)
|
|
428
|
+
|
|
429
|
+
# locations
|
|
430
|
+
lo = sub.add_parser(
|
|
431
|
+
"locations", help="list a provider's available countries/cities"
|
|
432
|
+
)
|
|
433
|
+
lo.add_argument("provider", help=_provider_help())
|
|
434
|
+
lo.add_argument("--country", help="show cities for this country")
|
|
435
|
+
lo.add_argument(
|
|
436
|
+
"--refresh", action="store_true", help="force-refresh the location list"
|
|
437
|
+
)
|
|
438
|
+
lo.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
439
|
+
lo.set_defaults(func=cmd_locations)
|
|
440
|
+
|
|
441
|
+
# top-level verbs
|
|
442
|
+
st = sub.add_parser("status", help="show alle + per-channel status")
|
|
443
|
+
st.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
444
|
+
st.set_defaults(func=cmd_status)
|
|
445
|
+
sub.add_parser(
|
|
446
|
+
"start", help="start sing-box (idle if no channels) + apply + probe"
|
|
447
|
+
).set_defaults(func=cmd_start)
|
|
448
|
+
sub.add_parser("stop", help="stop sing-box (channels kept in config)").set_defaults(
|
|
449
|
+
func=cmd_stop
|
|
450
|
+
)
|
|
451
|
+
sub.add_parser(
|
|
452
|
+
"restart", help="stop then start (reload after upgrades/config)"
|
|
453
|
+
).set_defaults(func=cmd_restart)
|
|
454
|
+
te = sub.add_parser(
|
|
455
|
+
"test", help="probe channels now; optionally speed-test healthy ones"
|
|
456
|
+
)
|
|
457
|
+
te.add_argument(
|
|
458
|
+
"--speed", action="store_true", help="also run download/upload tests"
|
|
459
|
+
)
|
|
460
|
+
te.add_argument("--channel", help="test only this channel (default: every channel)")
|
|
461
|
+
te.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
462
|
+
te.set_defaults(func=cmd_test)
|
|
463
|
+
me = sub.add_parser("metrics", help="show per-channel cumulative traffic totals")
|
|
464
|
+
me.add_argument(
|
|
465
|
+
"channel", nargs="?", help="filter to one channel by name (default: all)"
|
|
466
|
+
)
|
|
467
|
+
me.add_argument("--json", action="store_true", help="print machine-readable JSON")
|
|
468
|
+
me.set_defaults(func=cmd_metrics)
|
|
469
|
+
lg = sub.add_parser("logs", help="show alle's operation log")
|
|
470
|
+
lg.add_argument("-f", "--follow", action="store_true", help="stream new log lines")
|
|
471
|
+
lg.add_argument(
|
|
472
|
+
"-n", "--lines", type=int, default=200, help="lines to show (default 200)"
|
|
473
|
+
)
|
|
474
|
+
lg.set_defaults(func=cmd_logs)
|
|
475
|
+
sub.add_parser("version", help="print alle's version").set_defaults(
|
|
476
|
+
func=cmd_version
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
sub.add_parser("applier").set_defaults(
|
|
480
|
+
func=cmd_applier
|
|
481
|
+
) # internal: the daemon body
|
|
482
|
+
|
|
483
|
+
return p
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def main(argv=None) -> None:
|
|
487
|
+
args = build_parser().parse_args(argv)
|
|
488
|
+
try:
|
|
489
|
+
args.func(args)
|
|
490
|
+
except service.ServiceError as e:
|
|
491
|
+
sys.exit(str(e))
|
|
492
|
+
except (ProviderError, RuntimeError) as e:
|
|
493
|
+
sys.exit(f"ERROR: {e}")
|
|
494
|
+
except KeyboardInterrupt:
|
|
495
|
+
sys.exit(130)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
if __name__ == "__main__":
|
|
499
|
+
main()
|
alle/constants.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Shared constants for the single sing-box process alle runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Pinned sing-box release. alle always uses exactly this build, downloaded
|
|
6
|
+
# from upstream into ~/.alle/bin/sing-box@<version> and verified against the
|
|
7
|
+
# checksums below — never any other sing-box that happens to be on PATH.
|
|
8
|
+
SINGBOX_VERSION = "1.13.13"
|
|
9
|
+
|
|
10
|
+
# SHA256 of the *extracted sing-box binary* (not the tarball) for each supported
|
|
11
|
+
# platform. Upstream publishes no checksum file, so we pin the bytes ourselves
|
|
12
|
+
# (see THIRD_PARTY_NOTICES.md). Pinning the binary lets us verify both a fresh
|
|
13
|
+
# download and the on-disk file on every start. Regenerate on a version bump with
|
|
14
|
+
# scripts/gen_singbox_checksums.py.
|
|
15
|
+
SINGBOX_SHA256 = {
|
|
16
|
+
"darwin-amd64": "fb7ef2dead0a0231fa438e1cfdd4ad8a653a47e33f5cd1007560b33a12de7bf8",
|
|
17
|
+
"darwin-arm64": "b6056a1fa50e3abbe4d1c6bb85687396c6faf5c3d42f347e760191a5b218751d",
|
|
18
|
+
"linux-amd64": "2d8e80be91f196aff601f3ab2d5a855ac1dd5a447666cb7ec0cad99323b87dfe",
|
|
19
|
+
"linux-arm64": "d21721e273f5aab8a20a1bfda378602fdca2b40d9a7145a781bbdef1f496a1d5",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# Local address the Clash API (used only for traffic stats) listens on.
|
|
23
|
+
CLASH_API_ADDRESS = "127.0.0.1:9191"
|
|
24
|
+
|
|
25
|
+
# Tag prefixes inside the generated sing-box config, so a channel id maps to its
|
|
26
|
+
# inbound/outbound deterministically.
|
|
27
|
+
INBOUND_PREFIX = "in-"
|
|
28
|
+
OUTBOUND_PREFIX = "out-"
|
alle/credentials.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Per-provider login credentials, stored locally under ~/.alle/credentials.yaml.
|
|
2
|
+
|
|
3
|
+
alle authenticates to each provider with credentials the user adds explicitly
|
|
4
|
+
(``alle providers add <name>``) rather than reading them from the environment. A provider
|
|
5
|
+
is "configured" iff it has a complete credential entry in this file. The file is
|
|
6
|
+
written ``0600`` and never leaves the machine; alle displays only a masked
|
|
7
|
+
preview of secret values, never the raw credential.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from alle import paths
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _path():
|
|
21
|
+
return paths.state_dir() / "credentials.yaml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_all() -> dict[str, dict]:
|
|
25
|
+
p = _path()
|
|
26
|
+
if not p.exists():
|
|
27
|
+
return {}
|
|
28
|
+
data = yaml.safe_load(p.read_text()) or {}
|
|
29
|
+
return data.get("providers") or {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _save_all(providers: dict[str, dict]) -> None:
|
|
33
|
+
p = _path()
|
|
34
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
header = "# Managed by alle. Provider login credentials — keep this file private.\n"
|
|
36
|
+
# Created 0600 from the first byte — never a window where the file holds
|
|
37
|
+
# secrets under the default (usually world-readable) umask mode.
|
|
38
|
+
fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
|
|
39
|
+
with os.fdopen(fd, "w") as f:
|
|
40
|
+
f.write(header)
|
|
41
|
+
yaml.safe_dump(
|
|
42
|
+
{"providers": providers}, f, sort_keys=False, default_flow_style=False
|
|
43
|
+
)
|
|
44
|
+
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) # tighten a pre-existing looser file too
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get(provider: str) -> dict | None:
|
|
48
|
+
"""The stored credential dict for a provider, or None if not configured."""
|
|
49
|
+
return _load_all().get(provider)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def set_(provider: str, creds: dict) -> None:
|
|
53
|
+
"""Store (or replace) a provider's credentials, stripping surrounding space."""
|
|
54
|
+
cleaned = {k: (v.strip() if isinstance(v, str) else v) for k, v in creds.items()}
|
|
55
|
+
data = _load_all()
|
|
56
|
+
data[provider] = cleaned
|
|
57
|
+
_save_all(data)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def remove(provider: str) -> bool:
|
|
61
|
+
"""Forget a provider's credentials. True if anything was removed."""
|
|
62
|
+
data = _load_all()
|
|
63
|
+
if provider not in data:
|
|
64
|
+
return False
|
|
65
|
+
del data[provider]
|
|
66
|
+
_save_all(data)
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def configured() -> list[str]:
|
|
71
|
+
"""Providers that currently have a stored credential, sorted."""
|
|
72
|
+
return sorted(_load_all())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def mask(value: str, show: int = 4) -> str:
|
|
76
|
+
"""First and last ``show`` characters, the rest as stars (e.g. ``nGx4****a91k``).
|
|
77
|
+
|
|
78
|
+
Values too short to keep any plaintext on both ends are fully starred so a
|
|
79
|
+
secret is never partially leaked.
|
|
80
|
+
"""
|
|
81
|
+
v = value or ""
|
|
82
|
+
if len(v) <= show * 2:
|
|
83
|
+
return "*" * len(v)
|
|
84
|
+
return f"{v[:show]}{'*' * (len(v) - show * 2)}{v[-show:]}"
|