tlgr-cli 2.0.1__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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/core/paths.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""The `~/.tlgr` layout, in one module.
|
|
2
|
+
|
|
3
|
+
Every path tlgr writes is built here, and every one of them is built from a
|
|
4
|
+
*validated* alias. SEC-02 was the absence of that: an alias went straight into
|
|
5
|
+
`accounts_dir / alias`, so `-a ../../etc` addressed a directory outside the
|
|
6
|
+
tree, and a read path called `mkdir(parents=True)` on the way, so merely
|
|
7
|
+
asking about an account created one.
|
|
8
|
+
|
|
9
|
+
Two rules follow from that and are enforced here rather than remembered at
|
|
10
|
+
each call site:
|
|
11
|
+
|
|
12
|
+
* `validate_alias()` is called before an alias becomes part of a path;
|
|
13
|
+
* a *read* path never creates anything. Only the handful of functions whose
|
|
14
|
+
name says so (`ensure_*`) make a directory.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import stat
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from tlgr.core.errors import ConfigurationError, UsageError
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ALIAS_RE",
|
|
30
|
+
"PRODUCTION_MARKER",
|
|
31
|
+
"TlgrPaths",
|
|
32
|
+
"audit_permissions",
|
|
33
|
+
"default_base",
|
|
34
|
+
"is_production_home",
|
|
35
|
+
"refuse_production_home",
|
|
36
|
+
"secure_session_files",
|
|
37
|
+
"validate_alias",
|
|
38
|
+
"write_private",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
#: The one alias grammar. Anything outside it never reaches a path join.
|
|
42
|
+
ALIAS_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
|
43
|
+
|
|
44
|
+
_HOME_ENV = "TLGR_HOME"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def default_base() -> Path:
|
|
48
|
+
"""`$TLGR_HOME` when set, else `~/.tlgr`.
|
|
49
|
+
|
|
50
|
+
Read at call time rather than captured at import so a test (and a user
|
|
51
|
+
with two profiles) can point the whole tree somewhere else.
|
|
52
|
+
"""
|
|
53
|
+
override = os.environ.get(_HOME_ENV, "").strip()
|
|
54
|
+
if override:
|
|
55
|
+
return Path(override).expanduser()
|
|
56
|
+
return Path.home() / ".tlgr"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
#: A home directory carrying this marker belongs to a live deployment.
|
|
60
|
+
PRODUCTION_MARKER = ".production"
|
|
61
|
+
_ALLOW_PRODUCTION_ENV = "TLGR_ALLOW_PRODUCTION_HOME"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_production_home(base: Path) -> bool:
|
|
65
|
+
"""Is *base* marked as a live deployment?"""
|
|
66
|
+
try:
|
|
67
|
+
return (base / PRODUCTION_MARKER).exists()
|
|
68
|
+
except OSError: # pragma: no cover - an unreadable home is not ours to judge
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def refuse_production_home(base: Path) -> None:
|
|
73
|
+
"""Refuse to operate on a home marked as production unless told to.
|
|
74
|
+
|
|
75
|
+
A development checkout that resolves to the same `~/.tlgr` as the
|
|
76
|
+
installed, running tlgr is a live deploy by accident: on 2026-09-03 a dev
|
|
77
|
+
daemon started from a worktree bound the production socket, held the
|
|
78
|
+
production session files (``database is locked``) and served a registry
|
|
79
|
+
without the message routes the running agent needed. `TLGR_HOME` already
|
|
80
|
+
lets a dev point elsewhere; this makes forgetting to set it fail fast
|
|
81
|
+
instead of silently taking over. Touch ``<home>/.production`` on the
|
|
82
|
+
deployment; set ``TLGR_ALLOW_PRODUCTION_HOME=1`` only from the installed
|
|
83
|
+
tlgr that is meant to run there.
|
|
84
|
+
"""
|
|
85
|
+
if os.environ.get(_ALLOW_PRODUCTION_ENV, "").strip() == "1":
|
|
86
|
+
return
|
|
87
|
+
if (base / PRODUCTION_MARKER).exists():
|
|
88
|
+
raise ConfigurationError(
|
|
89
|
+
f"{base} is marked as a production home ({PRODUCTION_MARKER} present). "
|
|
90
|
+
"Set TLGR_HOME to a separate directory for development, or "
|
|
91
|
+
f"{_ALLOW_PRODUCTION_ENV}=1 when this really is the deployed tlgr."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_alias(alias: str) -> str:
|
|
96
|
+
"""Return *alias* if it can safely become a path segment, else raise.
|
|
97
|
+
|
|
98
|
+
Rejects the empty string, anything with a separator, `.`/`..`, and
|
|
99
|
+
anything over 64 characters — before the value is joined onto a path,
|
|
100
|
+
which is the only point at which the check is worth anything.
|
|
101
|
+
"""
|
|
102
|
+
if not isinstance(alias, str) or not ALIAS_RE.match(alias):
|
|
103
|
+
raise UsageError(
|
|
104
|
+
f"invalid account alias {alias!r}: use 1-64 characters from A-Z, a-z, 0-9, '_' and '-'",
|
|
105
|
+
field="account",
|
|
106
|
+
)
|
|
107
|
+
return alias
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def write_private(path: Path, data: bytes | str, *, mode: int = 0o600) -> None:
|
|
111
|
+
"""Write *data* to *path* so that it is never briefly world-readable.
|
|
112
|
+
|
|
113
|
+
The write goes to a temporary file in the same directory, is chmod-ed
|
|
114
|
+
before it is named, and is then atomically renamed into place. Writing
|
|
115
|
+
the file first and fixing the mode afterwards (SEC-07) leaves a window in
|
|
116
|
+
which a session, a token or a config with an `api_hash` in it is readable
|
|
117
|
+
by every process on the machine.
|
|
118
|
+
"""
|
|
119
|
+
payload = data.encode("utf-8") if isinstance(data, str) else data
|
|
120
|
+
parent = path.parent
|
|
121
|
+
parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
122
|
+
fd, tmp_name = tempfile.mkstemp(dir=str(parent), prefix=f".{path.name}.", suffix=".tmp")
|
|
123
|
+
tmp = Path(tmp_name)
|
|
124
|
+
try:
|
|
125
|
+
with os.fdopen(fd, "wb") as handle:
|
|
126
|
+
handle.write(payload)
|
|
127
|
+
handle.flush()
|
|
128
|
+
os.fsync(handle.fileno())
|
|
129
|
+
os.chmod(tmp, mode)
|
|
130
|
+
os.replace(tmp, path)
|
|
131
|
+
except BaseException:
|
|
132
|
+
tmp.unlink(missing_ok=True)
|
|
133
|
+
raise
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def secure_session_files(session_path: Path) -> None:
|
|
137
|
+
"""chmod 600 the session db and every sqlite sibling Telethon creates.
|
|
138
|
+
|
|
139
|
+
A session file is a complete account credential. v1 chmod-ed the one
|
|
140
|
+
imported by `account import` and left the one written by `account add`
|
|
141
|
+
at whatever the umask allowed — 0644 on a default Ubuntu box, which is
|
|
142
|
+
every other user on the machine holding the account.
|
|
143
|
+
|
|
144
|
+
Telethon appends `.session` and creates `-journal`/`-wal`/`-shm`
|
|
145
|
+
siblings at runtime, so the glob is the point: securing only the file
|
|
146
|
+
whose name we know leaves the write-ahead log readable.
|
|
147
|
+
"""
|
|
148
|
+
parent = session_path.parent
|
|
149
|
+
if not parent.exists():
|
|
150
|
+
return
|
|
151
|
+
for path in parent.glob(session_path.name + "*"):
|
|
152
|
+
with contextlib.suppress(OSError):
|
|
153
|
+
path.chmod(0o600)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TlgrPaths:
|
|
157
|
+
"""Every path under one base directory (§10.1)."""
|
|
158
|
+
|
|
159
|
+
__slots__ = ("base",)
|
|
160
|
+
|
|
161
|
+
def __init__(self, base: Path | str | None = None) -> None:
|
|
162
|
+
self.base = Path(base) if base is not None else default_base()
|
|
163
|
+
refuse_production_home(self.base)
|
|
164
|
+
|
|
165
|
+
# -- top level ---------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def config(self) -> Path:
|
|
169
|
+
return self.base / "config.toml"
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def accounts_file(self) -> Path:
|
|
173
|
+
return self.base / "accounts.json"
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def jobs(self) -> Path:
|
|
177
|
+
return self.base / "jobs.yaml"
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def webhook(self) -> Path:
|
|
181
|
+
return self.base / "webhook.toml"
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def socket(self) -> Path:
|
|
185
|
+
return self.base / "daemon.sock"
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def pid(self) -> Path:
|
|
189
|
+
return self.base / "daemon.pid"
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def lock(self) -> Path:
|
|
193
|
+
return self.base / "daemon.lock"
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def state(self) -> Path:
|
|
197
|
+
return self.base / "daemon.state"
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def token(self) -> Path:
|
|
201
|
+
return self.base / "ipc.token"
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def cursor_key(self) -> Path:
|
|
205
|
+
return self.base / "cursor.key"
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def identity(self) -> Path:
|
|
209
|
+
return self.base / "identity.json"
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def proxies(self) -> Path:
|
|
213
|
+
"""Saved proxies, including their passwords and MTProxy secrets (0600)."""
|
|
214
|
+
return self.base / "proxies.json"
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def dead_letter(self) -> Path:
|
|
218
|
+
return self.base / "dead_letter.jsonl"
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def logs(self) -> Path:
|
|
222
|
+
return self.base / "logs"
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def log_file(self) -> Path:
|
|
226
|
+
return self.logs / "daemon.log"
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def downloads(self) -> Path:
|
|
230
|
+
return self.base / "downloads"
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def cache(self) -> Path:
|
|
234
|
+
return self.base / "cache"
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def accounts(self) -> Path:
|
|
238
|
+
return self.base / "accounts"
|
|
239
|
+
|
|
240
|
+
# -- per account -------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
def account_dir(self, alias: str) -> Path:
|
|
243
|
+
return self.accounts / validate_alias(alias)
|
|
244
|
+
|
|
245
|
+
def session(self, alias: str) -> Path:
|
|
246
|
+
return self.account_dir(alias) / "session"
|
|
247
|
+
|
|
248
|
+
def session_file(self, alias: str) -> Path:
|
|
249
|
+
return self.account_dir(alias) / "session.session"
|
|
250
|
+
|
|
251
|
+
def session_lock(self, alias: str) -> Path:
|
|
252
|
+
return self.account_dir(alias) / "session.lock"
|
|
253
|
+
|
|
254
|
+
def credentials(self, alias: str) -> Path:
|
|
255
|
+
return self.account_dir(alias) / "config.json"
|
|
256
|
+
|
|
257
|
+
def peers_db(self, alias: str) -> Path:
|
|
258
|
+
return self.account_dir(alias) / "peers.json"
|
|
259
|
+
|
|
260
|
+
def flood(self, alias: str) -> Path:
|
|
261
|
+
return self.account_dir(alias) / "flood.json"
|
|
262
|
+
|
|
263
|
+
def events_state(self, alias: str) -> Path:
|
|
264
|
+
return self.account_dir(alias) / "events.state"
|
|
265
|
+
|
|
266
|
+
def outbox(self, alias: str) -> Path:
|
|
267
|
+
return self.account_dir(alias) / "outbox.jsonl"
|
|
268
|
+
|
|
269
|
+
# -- creation ----------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def ensure_base(self) -> Path:
|
|
272
|
+
self.base.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
273
|
+
return self.base
|
|
274
|
+
|
|
275
|
+
def ensure_logs(self) -> Path:
|
|
276
|
+
self.logs.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
277
|
+
return self.logs
|
|
278
|
+
|
|
279
|
+
def ensure_downloads(self) -> Path:
|
|
280
|
+
self.downloads.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
281
|
+
return self.downloads
|
|
282
|
+
|
|
283
|
+
def ensure_cache(self) -> Path:
|
|
284
|
+
self.cache.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
285
|
+
return self.cache
|
|
286
|
+
|
|
287
|
+
def ensure_account_dir(self, alias: str) -> Path:
|
|
288
|
+
directory = self.account_dir(alias)
|
|
289
|
+
directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
290
|
+
return directory
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
#: Files whose leak is equivalent to handing over the account.
|
|
294
|
+
_SECRET_GLOBS = (
|
|
295
|
+
"accounts/*/session*",
|
|
296
|
+
"accounts/*/config.json",
|
|
297
|
+
"ipc.token",
|
|
298
|
+
"cursor.key",
|
|
299
|
+
"webhook.toml",
|
|
300
|
+
"proxies.json",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
_SECRET_MODE = 0o600
|
|
304
|
+
_DIR_MODE = 0o700
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def audit_permissions(base: Path, *, fix: bool = True) -> list[str]:
|
|
308
|
+
"""Check (and optionally fix) the modes of everything secret under *base*.
|
|
309
|
+
|
|
310
|
+
Returns the list of problems that could **not** be fixed; the daemon
|
|
311
|
+
refuses to start when it is non-empty rather than opening a session file
|
|
312
|
+
that the rest of the machine can read. Ownership is checked too: a file
|
|
313
|
+
owned by someone else cannot be chmod-ed by us and is always a refusal.
|
|
314
|
+
"""
|
|
315
|
+
problems: list[str] = []
|
|
316
|
+
uid = os.getuid()
|
|
317
|
+
|
|
318
|
+
def check(path: Path, want: int) -> None:
|
|
319
|
+
try:
|
|
320
|
+
info = path.lstat()
|
|
321
|
+
except OSError:
|
|
322
|
+
return
|
|
323
|
+
if info.st_uid != uid:
|
|
324
|
+
problems.append(f"{path} is owned by uid {info.st_uid}, not {uid}")
|
|
325
|
+
return
|
|
326
|
+
actual = stat.S_IMODE(info.st_mode)
|
|
327
|
+
if actual & ~want:
|
|
328
|
+
if not fix:
|
|
329
|
+
problems.append(f"{path} is mode {actual:04o}, want {want:04o}")
|
|
330
|
+
return
|
|
331
|
+
try:
|
|
332
|
+
os.chmod(path, want)
|
|
333
|
+
except OSError as exc:
|
|
334
|
+
problems.append(f"{path} is mode {actual:04o}; chmod {want:04o} failed: {exc}")
|
|
335
|
+
|
|
336
|
+
if base.exists():
|
|
337
|
+
check(base, _DIR_MODE)
|
|
338
|
+
accounts = base / "accounts"
|
|
339
|
+
if accounts.exists():
|
|
340
|
+
check(accounts, _DIR_MODE)
|
|
341
|
+
for child in sorted(accounts.iterdir()):
|
|
342
|
+
if child.is_dir():
|
|
343
|
+
check(child, _DIR_MODE)
|
|
344
|
+
for pattern in _SECRET_GLOBS:
|
|
345
|
+
for path in sorted(base.glob(pattern)):
|
|
346
|
+
if path.is_file():
|
|
347
|
+
check(path, _SECRET_MODE)
|
|
348
|
+
return problems
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def require_safe_permissions(base: Path) -> None:
|
|
352
|
+
"""Raise `ConfigurationError` naming the exact `chmod` when the tree is unsafe."""
|
|
353
|
+
problems = audit_permissions(base, fix=True)
|
|
354
|
+
if problems:
|
|
355
|
+
listing = "\n ".join(problems)
|
|
356
|
+
raise ConfigurationError(
|
|
357
|
+
"refusing to start: files under "
|
|
358
|
+
f"{base} are not private:\n {listing}\n"
|
|
359
|
+
f"Fix with: chmod -R go-rwx {base}"
|
|
360
|
+
)
|