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 ADDED
@@ -0,0 +1,8 @@
1
+ """alle — multi-location VPN gateways via one sing-box process (WireGuard)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("alle-proxy")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+unknown"
alle/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from alle.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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:]}"