agentconnect-cli 0.1.0a1__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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
"""The owner's listing: publish, door renewal, withdraw and status
|
|
2
|
+
(SPEC 6.6.1, 13.2; docs/discovery.md).
|
|
3
|
+
|
|
4
|
+
One listing per runtime, for one (directory, name). Publishing is the
|
|
5
|
+
human's decision (the local API's human-only routes call :meth:`preview`
|
|
6
|
+
and :meth:`publish`); everything after it runs without anyone:
|
|
7
|
+
|
|
8
|
+
* **Doors.** A door is an HPKE key pair the core generates, registered at
|
|
9
|
+
the relay for this instance's mailbox. Its private key is kept in the
|
|
10
|
+
daemon's encrypted database (and in memory as an ``agentconnect_core.Door``,
|
|
11
|
+
which zeroizes it when dropped) and deleted an hour after the door
|
|
12
|
+
expires, once every knock it could have received has lapsed. A door the
|
|
13
|
+
relay may hold is never forgotten: until the relay confirms it closed
|
|
14
|
+
(``door_not_open`` or success), its row stays, ``closing``, without its key.
|
|
15
|
+
* **Renewal.** At half a door's lifetime the discovery worker registers a
|
|
16
|
+
fresh door and publishes the listing again, re-signed by the core with
|
|
17
|
+
only ``door``, ``seq`` and ``issued_at`` changed (``Listing.renew``), so
|
|
18
|
+
a renewal can't change what the human approved. The old door stays
|
|
19
|
+
registered until it expires. A renewal that keeps failing for an hour,
|
|
20
|
+
or a door that expires before a renewal lands, raises ``listing_stale``.
|
|
21
|
+
Only ``token_revoked``, ``bad_token`` and ``name_held`` stop renewing.
|
|
22
|
+
* **Withdraw.** Either token may withdraw (it only reduces exposure, SPEC
|
|
23
|
+
13.2.3): every door is closed at the relay first, the keys are deleted at
|
|
24
|
+
once, then the signed withdrawal goes to the directory.
|
|
25
|
+
|
|
26
|
+
A failed publish puts the listing back exactly as it was before the attempt
|
|
27
|
+
(``restore_state``): a withdrawn listing stays withdrawn.
|
|
28
|
+
|
|
29
|
+
Every step is recorded before the next one starts, so a crash anywhere
|
|
30
|
+
resumes from the database: a door ``registering`` is registered again
|
|
31
|
+
(the relay's "already exists" counts as done), signed bytes in ``pending``
|
|
32
|
+
are posted again byte for byte (idempotent at the directory).
|
|
33
|
+
|
|
34
|
+
``seq`` is ``max(max_signed_seq + 1, now)`` and ``max_signed_seq`` is never
|
|
35
|
+
lowered, so a ``seq`` is never reused, not even after a lost answer.
|
|
36
|
+
|
|
37
|
+
What a directory answers is untrusted: its codes are mapped onto the set
|
|
38
|
+
this daemon knows (``agentconnect.directory_client``), its owner state onto
|
|
39
|
+
the SPEC's states, and its ``detail`` is cleaned before a person sees it.
|
|
40
|
+
Logs carry event types and reason codes only: no directory, name, door id
|
|
41
|
+
or domain (SPEC 8.3, docs/discovery.md).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import asyncio
|
|
47
|
+
import hashlib
|
|
48
|
+
import json
|
|
49
|
+
from collections.abc import Callable
|
|
50
|
+
from dataclasses import dataclass
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
import agentconnect_core as ac
|
|
54
|
+
from agentconnect import log
|
|
55
|
+
from agentconnect.backoff import Backoff
|
|
56
|
+
from agentconnect.config import DirectoryConfig
|
|
57
|
+
from agentconnect.directory_client import (
|
|
58
|
+
DirectoryClient,
|
|
59
|
+
DirectoryError,
|
|
60
|
+
clean_detail,
|
|
61
|
+
owner_state,
|
|
62
|
+
)
|
|
63
|
+
from agentconnect.ownerfile import Fetcher, OwnerFileFetcher
|
|
64
|
+
from agentconnect.relay import RelayError, b64
|
|
65
|
+
from agentconnect.runtime.engine import Engine, canonical
|
|
66
|
+
from agentconnect.runtime.errors import ApiError
|
|
67
|
+
from agentconnect.store import Store
|
|
68
|
+
|
|
69
|
+
LOG = log.get("agentconnect.listing")
|
|
70
|
+
|
|
71
|
+
TOKEN_WARNING_SECS = 14 * 86_400 # SPEC 13.2.1: warn 14 days before the token expires
|
|
72
|
+
MIN_TOKEN_LEFT_SECS = 7200 # no renewal with less token life left
|
|
73
|
+
STALE_AFTER_SECS = 3600 # a renewal or withdrawal failing this long is reported
|
|
74
|
+
WITHDRAW_GIVE_UP_SECS = 7 * 86_400 # past every door's life: stop retrying the withdrawal
|
|
75
|
+
DOOR_WARNING_SECS = 86_400 # a door this close to expiry while a republish waits
|
|
76
|
+
RETRY_BASE_SECS = 60
|
|
77
|
+
RETRY_CAP_SECS = 3600
|
|
78
|
+
CLOSE_RETRY_SECS = 300
|
|
79
|
+
EXPIRY_SLACK_SECS = 3600 # a door the relay found too long is retried this much shorter
|
|
80
|
+
MAX_IDLE_PASSES = 3 # passes in a row with work due at once before the worker pauses
|
|
81
|
+
|
|
82
|
+
#: The card file's fields: the listing's text (``ListingContent``). An Agent
|
|
83
|
+
#: Card's other fields (its interfaces, signatures, extensions) are not text
|
|
84
|
+
#: the human writes; the core adds what a listing needs.
|
|
85
|
+
CONTENT_FIELDS = frozenset(
|
|
86
|
+
{
|
|
87
|
+
"name",
|
|
88
|
+
"description",
|
|
89
|
+
"version",
|
|
90
|
+
"provider",
|
|
91
|
+
"capabilities",
|
|
92
|
+
"defaultInputModes",
|
|
93
|
+
"defaultOutputModes",
|
|
94
|
+
"skills",
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
LIVE = frozenset({"publishing", "published", "stale"})
|
|
98
|
+
# States that hold the runtime's one listing (D2): another can't be published.
|
|
99
|
+
HOLDING = LIVE | {"withdrawing"}
|
|
100
|
+
#: ``withdrawn_locally``: a forced withdrawal; the directory may still list it.
|
|
101
|
+
WITHDRAWABLE = HOLDING | {"expired", "withdraw_failed", "withdrawn_locally"}
|
|
102
|
+
#: What ``force`` may end: a withdrawal the directory hasn't taken.
|
|
103
|
+
FORCEABLE = frozenset({"withdrawing", "withdraw_failed"})
|
|
104
|
+
PENDING_DOOR = frozenset({"registering", "registered"})
|
|
105
|
+
OPEN_DOOR = frozenset({"current", "retiring"})
|
|
106
|
+
#: SPEC 6.6.1/13.2: the refusals of a renewal that stop renewing. Any other
|
|
107
|
+
#: refusal is retried (with a fresh door and signature) and reported.
|
|
108
|
+
STOP_RENEWING = frozenset({"token_revoked", "bad_token", "name_held"})
|
|
109
|
+
#: Relay answers to a door registration that say the relay doesn't hold it.
|
|
110
|
+
NOT_REGISTERED = frozenset({"bad_request", "quota_exceeded"})
|
|
111
|
+
#: The directory's refusals (directory/README.md) and the status the local
|
|
112
|
+
#: API answers them with; any other refusal is ``502 directory``.
|
|
113
|
+
REFUSAL_STATUS = {
|
|
114
|
+
"name_held": 409,
|
|
115
|
+
"bad_token": 403,
|
|
116
|
+
"token_revoked": 403,
|
|
117
|
+
"unknown_key": 403,
|
|
118
|
+
"bad_signature": 403,
|
|
119
|
+
"wrong_identity": 403,
|
|
120
|
+
"owner_revoked": 403,
|
|
121
|
+
"wrong_relay": 400,
|
|
122
|
+
"wrong_directory": 400,
|
|
123
|
+
"bad_door": 400,
|
|
124
|
+
"stale_ts": 400,
|
|
125
|
+
"bad_listing": 400,
|
|
126
|
+
"unknown_directory": 400,
|
|
127
|
+
"too_large": 413,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _warning(code: str, message: str) -> dict[str, str]:
|
|
132
|
+
return {"code": code, "message": message}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _due(at: int | None, now: int) -> bool:
|
|
136
|
+
return at is None or at <= now
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _error(code: str, at: int) -> str:
|
|
140
|
+
return json.dumps({"code": code, "at": at})
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class Draft:
|
|
145
|
+
"""A checked publish request: what the human is shown and approves."""
|
|
146
|
+
|
|
147
|
+
entry: DirectoryConfig
|
|
148
|
+
name: str
|
|
149
|
+
token: bytes # the ListingToken, canonical
|
|
150
|
+
token_info: dict[str, Any]
|
|
151
|
+
content: bytes # the canonical ListingContent
|
|
152
|
+
owner_domain: str | None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def sha256(self) -> str:
|
|
156
|
+
"""What the human approves: everything that will be signed except the
|
|
157
|
+
door, ``seq`` and ``issued_at``, which publish chooses."""
|
|
158
|
+
approved = {
|
|
159
|
+
"content": json.loads(self.content),
|
|
160
|
+
"directory": self.entry.host,
|
|
161
|
+
"name": self.name,
|
|
162
|
+
"owner_domain": self.owner_domain,
|
|
163
|
+
"token": json.loads(self.token),
|
|
164
|
+
}
|
|
165
|
+
return hashlib.sha256(canonical(approved)).hexdigest()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class ListingManager:
|
|
169
|
+
"""The runtime's listing and its doors (one :class:`Engine`)."""
|
|
170
|
+
|
|
171
|
+
def __init__(self, engine: Engine) -> None:
|
|
172
|
+
self._engine = engine
|
|
173
|
+
self._lock = asyncio.Lock() # one publish, withdraw or worker pass at a time
|
|
174
|
+
self._wake = asyncio.Event()
|
|
175
|
+
options = engine.options
|
|
176
|
+
self._fetcher: Fetcher = options.owner_fetcher or OwnerFileFetcher()
|
|
177
|
+
# door_id -> its key pair: what the owner opens knocks with.
|
|
178
|
+
self._doors: dict[str, ac.Door] = {}
|
|
179
|
+
# door_id -> (next close attempt, attempts) for doors the relay
|
|
180
|
+
# hasn't confirmed closed; after a restart they are tried at once.
|
|
181
|
+
self._close_retry: dict[str, tuple[int, int]] = {}
|
|
182
|
+
self._load_doors()
|
|
183
|
+
|
|
184
|
+
# -- plumbing -------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def _store(self) -> Store:
|
|
188
|
+
return self._engine.store
|
|
189
|
+
|
|
190
|
+
def _now(self) -> int:
|
|
191
|
+
return self._engine.now()
|
|
192
|
+
|
|
193
|
+
def _lifetime(self) -> int:
|
|
194
|
+
seam = self._engine.options.door_lifetime_secs
|
|
195
|
+
return seam if seam is not None else self._engine.config.listing_door_lifetime_secs
|
|
196
|
+
|
|
197
|
+
def _client(self, row: dict[str, Any]) -> DirectoryClient:
|
|
198
|
+
entry = self._engine.config.directory(str(row["directory"]))
|
|
199
|
+
if entry is None:
|
|
200
|
+
raise DirectoryError("unknown_directory", 400, "the directory is no longer configured")
|
|
201
|
+
return DirectoryClient(entry, transport=self._engine.options.directory_transport)
|
|
202
|
+
|
|
203
|
+
def _refresh(self, row: dict[str, Any]) -> dict[str, Any]:
|
|
204
|
+
fresh = self._store.listing(row["directory"], row["name"])
|
|
205
|
+
if fresh is None: # pragma: no cover - rows are never deleted
|
|
206
|
+
msg = "listing row vanished"
|
|
207
|
+
raise RuntimeError(msg)
|
|
208
|
+
return fresh
|
|
209
|
+
|
|
210
|
+
def _door_row(self, door_id: str) -> dict[str, Any] | None:
|
|
211
|
+
return next((d for d in self._store.doors() if d["door_id"] == door_id), None)
|
|
212
|
+
|
|
213
|
+
def _load_doors(self) -> None:
|
|
214
|
+
"""The door keys from the database, once. A key that doesn't load
|
|
215
|
+
(the core checks the key pair) is dropped and its door closed by the
|
|
216
|
+
worker; it is logged without its id."""
|
|
217
|
+
for door_id, secret in self._store.door_secrets():
|
|
218
|
+
try:
|
|
219
|
+
door = ac.Door.from_secret_bytes(secret)
|
|
220
|
+
except (ac.AgentConnectError, ac.CoreError):
|
|
221
|
+
LOG.warning("door_key_unreadable")
|
|
222
|
+
self._store.drop_door_secret(door_id, "closing")
|
|
223
|
+
continue
|
|
224
|
+
self._doors[door_id] = door
|
|
225
|
+
|
|
226
|
+
def door(self, door_id: str) -> ac.Door | None:
|
|
227
|
+
"""The key pair of one of this runtime's doors, while it is kept."""
|
|
228
|
+
return self._doors.get(door_id)
|
|
229
|
+
|
|
230
|
+
async def _commit(
|
|
231
|
+
self, effects: Callable[[], None], events: list[tuple[str, str, dict[str, Any]]]
|
|
232
|
+
) -> None:
|
|
233
|
+
"""``effects`` and the events they raise, in one transaction; the
|
|
234
|
+
adapter hears of the events once it commits. Each event's id comes
|
|
235
|
+
from its cause, so a repeat after a crash is the same event."""
|
|
236
|
+
|
|
237
|
+
async def body() -> str:
|
|
238
|
+
effects()
|
|
239
|
+
for kind, cause, data in events:
|
|
240
|
+
await self._engine.emit(None, kind, cause=cause, **data)
|
|
241
|
+
return ""
|
|
242
|
+
|
|
243
|
+
await self._engine._apply("listing", body)
|
|
244
|
+
|
|
245
|
+
# -- the human's publish ----------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def _draft(self, body: dict[str, Any]) -> Draft:
|
|
248
|
+
directory, card, token = body.get("directory"), body.get("card"), body.get("token")
|
|
249
|
+
owner_domain = body.get("owner_domain")
|
|
250
|
+
if not isinstance(directory, str):
|
|
251
|
+
raise ApiError(400, "bad_request", "directory must be a string")
|
|
252
|
+
if not isinstance(card, dict) or not isinstance(token, dict):
|
|
253
|
+
raise ApiError(400, "bad_request", "card and token must be JSON objects")
|
|
254
|
+
if owner_domain is not None and not isinstance(owner_domain, str):
|
|
255
|
+
raise ApiError(400, "bad_request", "owner_domain must be a string")
|
|
256
|
+
entry = self._engine.config.directory(directory)
|
|
257
|
+
if entry is None:
|
|
258
|
+
msg = "that directory is not configured: add a [[directory]] table with its keys"
|
|
259
|
+
raise ApiError(400, "unknown_directory", msg)
|
|
260
|
+
now = self._now()
|
|
261
|
+
token_bytes = canonical(token)
|
|
262
|
+
try:
|
|
263
|
+
info = ac.verify_listing_token(token_bytes, entry.host, list(entry.keys), now)
|
|
264
|
+
except ac.AgentConnectError as e:
|
|
265
|
+
raise _token_error(e) from None
|
|
266
|
+
name = str(info["name"])
|
|
267
|
+
pinned = info["owner_domain"]
|
|
268
|
+
if owner_domain is not None and pinned is not None and owner_domain != pinned:
|
|
269
|
+
msg = f"the token pins the owner domain {pinned}"
|
|
270
|
+
raise ApiError(400, "bad_request", msg)
|
|
271
|
+
domain = pinned if pinned is not None else owner_domain
|
|
272
|
+
try:
|
|
273
|
+
ac.check_token_binding(token_bytes, entry.host, name, self._engine.me, domain)
|
|
274
|
+
except ac.AgentConnectError:
|
|
275
|
+
msg = (
|
|
276
|
+
"the token is for another identity: the operator issues it for the identity "
|
|
277
|
+
"`agentconnect status` shows"
|
|
278
|
+
)
|
|
279
|
+
raise ApiError(403, "token_not_for_identity", msg) from None
|
|
280
|
+
extra = sorted(set(card) - CONTENT_FIELDS)
|
|
281
|
+
if extra:
|
|
282
|
+
msg = f"the card file holds the listing's text only; remove: {', '.join(extra)}"
|
|
283
|
+
raise ApiError(400, "bad_card", msg)
|
|
284
|
+
try:
|
|
285
|
+
content = ac.canonical_listing_content(canonical(card))
|
|
286
|
+
# A dry run of the signature: the owner-domain and provider rules
|
|
287
|
+
# are checked now, before a door is registered.
|
|
288
|
+
ac.sign_listing_content(
|
|
289
|
+
content,
|
|
290
|
+
self._engine.identity,
|
|
291
|
+
directory=entry.host,
|
|
292
|
+
relay=self._engine.config.relay_host,
|
|
293
|
+
seq=1,
|
|
294
|
+
issued_at=now,
|
|
295
|
+
token_json=token_bytes,
|
|
296
|
+
owner_domain=domain,
|
|
297
|
+
)
|
|
298
|
+
except ac.AgentConnectError as e:
|
|
299
|
+
raise ApiError(400, "bad_card", str(e)) from None
|
|
300
|
+
return Draft(entry, name, token_bytes, info, content, domain)
|
|
301
|
+
|
|
302
|
+
def _single(self, draft: Draft) -> None:
|
|
303
|
+
"""D2: one listing per runtime. Another one must be withdrawn first."""
|
|
304
|
+
for row in self._store.listings():
|
|
305
|
+
same = (row["directory"], row["name"]) == (draft.entry.host, draft.name)
|
|
306
|
+
if not same and row["state"] in HOLDING:
|
|
307
|
+
msg = "this runtime already has a listing; withdraw it first"
|
|
308
|
+
raise ApiError(409, "listing_exists", msg)
|
|
309
|
+
|
|
310
|
+
def _warnings(self, draft: Draft) -> list[dict[str, str]]:
|
|
311
|
+
warnings = []
|
|
312
|
+
groups = len([g for g in self._store.groups() if g.status == "active"])
|
|
313
|
+
if groups:
|
|
314
|
+
warnings.append(
|
|
315
|
+
_warning(
|
|
316
|
+
"identity_in_groups",
|
|
317
|
+
f"This identity is a member of {groups} group(s). A listed identity is "
|
|
318
|
+
"public: anyone can tell that a member with it, in any group, is the "
|
|
319
|
+
"listed agent. A dedicated identity for listing is safer.",
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
if int(draft.token_info["expires_at"]) - self._now() < TOKEN_WARNING_SECS:
|
|
323
|
+
warnings.append(
|
|
324
|
+
_warning(
|
|
325
|
+
"token_expires_soon",
|
|
326
|
+
"The listing token expires within 14 days; ask the operator for a new one.",
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
return warnings
|
|
330
|
+
|
|
331
|
+
async def _owner_file_warning(self, draft: Draft) -> dict[str, str] | None:
|
|
332
|
+
"""Whether the owner domain already vouches for this identity in this
|
|
333
|
+
listing, from this daemon's own fetch of its owner file."""
|
|
334
|
+
domain = str(draft.owner_domain)
|
|
335
|
+
fetched = await self._fetcher.fetch(domain)
|
|
336
|
+
result = "failed"
|
|
337
|
+
if fetched.data is not None:
|
|
338
|
+
result = ac.owner_fetch_result(
|
|
339
|
+
fetched.data, self._engine.me, draft.entry.host, draft.name
|
|
340
|
+
)["result"]
|
|
341
|
+
if result == "vouches":
|
|
342
|
+
return None
|
|
343
|
+
if result == "failed":
|
|
344
|
+
return _warning(
|
|
345
|
+
"owner_file_unreachable",
|
|
346
|
+
f"{domain}/.well-known/agentconnect.json could not be fetched or read; until "
|
|
347
|
+
"it lists the entry below, the owner shows as not verified.",
|
|
348
|
+
)
|
|
349
|
+
return _warning(
|
|
350
|
+
"owner_file_missing_identity",
|
|
351
|
+
f"{domain}/.well-known/agentconnect.json doesn't vouch for this identity in this "
|
|
352
|
+
"listing; add the entry below.",
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
async def preview(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
356
|
+
"""What :meth:`publish` would sign, for the human to check: nothing
|
|
357
|
+
is registered, signed or sent (other than the owner-file fetch)."""
|
|
358
|
+
draft = self._draft(body)
|
|
359
|
+
self._single(draft)
|
|
360
|
+
warnings = self._warnings(draft)
|
|
361
|
+
entry = None
|
|
362
|
+
if draft.owner_domain is not None:
|
|
363
|
+
entry = {
|
|
364
|
+
"identity": self._engine.me,
|
|
365
|
+
"fingerprint": self._engine.identity.fingerprint,
|
|
366
|
+
"listings": [f"{draft.entry.host}/a/{draft.name}"],
|
|
367
|
+
}
|
|
368
|
+
warning = await self._owner_file_warning(draft)
|
|
369
|
+
if warning is not None:
|
|
370
|
+
warnings.append(warning)
|
|
371
|
+
info = draft.token_info
|
|
372
|
+
return {
|
|
373
|
+
"directory": draft.entry.host,
|
|
374
|
+
"name": draft.name,
|
|
375
|
+
"identity": self._engine.me,
|
|
376
|
+
"fingerprint": self._engine.identity.fingerprint,
|
|
377
|
+
"content": json.loads(draft.content),
|
|
378
|
+
"content_sha256": draft.sha256,
|
|
379
|
+
"owner_domain": draft.owner_domain,
|
|
380
|
+
"token": {
|
|
381
|
+
"token_id": b64(info["token_id"]),
|
|
382
|
+
"issued_at": int(info["issued_at"]),
|
|
383
|
+
"expires_at": int(info["expires_at"]),
|
|
384
|
+
"key_id": b64(info["key_id"]),
|
|
385
|
+
},
|
|
386
|
+
"owner_file_entry": entry,
|
|
387
|
+
"warnings": warnings,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async def publish(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
391
|
+
"""The human's publish: register a fresh door, sign, post. A
|
|
392
|
+
directory that doesn't answer is retried by the worker with the same
|
|
393
|
+
bytes (``retrying``); a refusal raises with the directory's code."""
|
|
394
|
+
draft = self._draft(body)
|
|
395
|
+
sha = body.get("content_sha256")
|
|
396
|
+
if not isinstance(sha, str):
|
|
397
|
+
raise ApiError(400, "bad_request", "content_sha256 must be a string")
|
|
398
|
+
if sha != draft.sha256:
|
|
399
|
+
msg = "the card, the token or the directory changed since it was shown; preview again"
|
|
400
|
+
raise ApiError(409, "card_changed", msg)
|
|
401
|
+
async with self._lock:
|
|
402
|
+
self._single(draft)
|
|
403
|
+
row = await self._begin(draft)
|
|
404
|
+
outcome = await self._round(row, interactive=True)
|
|
405
|
+
row = self._refresh(row)
|
|
406
|
+
self._wake.set()
|
|
407
|
+
doors = self._store.doors(row["directory"], row["name"])
|
|
408
|
+
door = next((d for d in reversed(doors) if d["state"] in ("current", *PENDING_DOOR)), None)
|
|
409
|
+
retrying = outcome == "retrying"
|
|
410
|
+
return {
|
|
411
|
+
"directory": row["directory"],
|
|
412
|
+
"name": row["name"],
|
|
413
|
+
"url": _url(row),
|
|
414
|
+
"seq": int(row["max_signed_seq"] if retrying else row["seq"]),
|
|
415
|
+
"state": row["state"],
|
|
416
|
+
"door_expires_at": None if door is None else int(door["expires_at"]),
|
|
417
|
+
"renews_at": row["next_renewal_at"],
|
|
418
|
+
"token_expires_at": int(row["token_expires_at"]),
|
|
419
|
+
"owner_domain": row["owner_domain"],
|
|
420
|
+
"owner_state": row["owner_state"],
|
|
421
|
+
"warnings": self._warnings(draft),
|
|
422
|
+
"retrying": retrying,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async def _begin(self, draft: Draft) -> dict[str, Any]:
|
|
426
|
+
"""Record the approved text and token as ``publishing``, with the
|
|
427
|
+
state to go back to if this publish fails; a door still waiting from
|
|
428
|
+
an earlier attempt is closed."""
|
|
429
|
+
host, name, now = draft.entry.host, draft.name, self._now()
|
|
430
|
+
prev = self._store.listing(host, name)
|
|
431
|
+
stale_doors = [d for d in self._store.doors(host, name) if d["state"] in PENDING_DOOR]
|
|
432
|
+
token = draft.token.decode()
|
|
433
|
+
restore_state, restore_renewal_at = "refused", None
|
|
434
|
+
if prev is not None and prev["state"] == "publishing": # a republish over a retry
|
|
435
|
+
restore_state, restore_renewal_at = prev["restore_state"], prev["restore_renewal_at"]
|
|
436
|
+
elif prev is not None:
|
|
437
|
+
restore_state, restore_renewal_at = prev["state"], prev["next_renewal_at"]
|
|
438
|
+
row = {
|
|
439
|
+
**(prev or {}),
|
|
440
|
+
"directory": host,
|
|
441
|
+
"name": name,
|
|
442
|
+
"state": "publishing",
|
|
443
|
+
"content": draft.content.decode(),
|
|
444
|
+
"token": token,
|
|
445
|
+
"token_expires_at": int(draft.token_info["expires_at"]),
|
|
446
|
+
"owner_domain": draft.owner_domain,
|
|
447
|
+
"pending": None,
|
|
448
|
+
"pending_door": None,
|
|
449
|
+
"next_renewal_at": None,
|
|
450
|
+
"retry_at": None,
|
|
451
|
+
"attempts": 0,
|
|
452
|
+
"failing_since": None,
|
|
453
|
+
"last_error": None,
|
|
454
|
+
"token_warned": 0 if prev is None or prev["token"] != token else prev["token_warned"],
|
|
455
|
+
"restore_state": restore_state,
|
|
456
|
+
"restore_renewal_at": restore_renewal_at,
|
|
457
|
+
"created_at": now if prev is None else prev["created_at"],
|
|
458
|
+
"updated_at": now,
|
|
459
|
+
}
|
|
460
|
+
with self._store.tx():
|
|
461
|
+
self._store.put_listing(row)
|
|
462
|
+
for d in stale_doors:
|
|
463
|
+
await self._close_door(d)
|
|
464
|
+
return self._refresh(row)
|
|
465
|
+
|
|
466
|
+
# -- doors --------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
def _pending_door(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
|
469
|
+
if row["pending_door"] is not None:
|
|
470
|
+
door = self._door_row(str(row["pending_door"]))
|
|
471
|
+
if door is not None and str(door["door_id"]) in self._doors:
|
|
472
|
+
return door
|
|
473
|
+
# Signed for a door that is gone: sign again for a fresh one.
|
|
474
|
+
host, name = row["directory"], row["name"]
|
|
475
|
+
self._store.update_listing(host, name, pending=None, pending_door=None)
|
|
476
|
+
row["pending"] = row["pending_door"] = None
|
|
477
|
+
doors = self._store.doors(row["directory"], row["name"])
|
|
478
|
+
return next(
|
|
479
|
+
(d for d in doors if d["state"] in PENDING_DOOR and str(d["door_id"]) in self._doors),
|
|
480
|
+
None,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
def _new_door(self, row: dict[str, Any], expires_at: int | None = None) -> dict[str, Any]:
|
|
484
|
+
"""A fresh door, recorded (``registering``, with its key) before the
|
|
485
|
+
relay hears of it."""
|
|
486
|
+
now = self._now()
|
|
487
|
+
if expires_at is None:
|
|
488
|
+
expires_at = min(now + self._lifetime(), int(row["token_expires_at"]))
|
|
489
|
+
door = self._engine.client.generate_door(expires_at)
|
|
490
|
+
door_id = b64(door.id)
|
|
491
|
+
with self._store.tx():
|
|
492
|
+
self._store.add_door(
|
|
493
|
+
door_id=door_id,
|
|
494
|
+
directory=row["directory"],
|
|
495
|
+
name=row["name"],
|
|
496
|
+
secret=door.to_secret_bytes(),
|
|
497
|
+
expires_at=expires_at,
|
|
498
|
+
now=now,
|
|
499
|
+
)
|
|
500
|
+
self._doors[door_id] = door
|
|
501
|
+
found = self._door_row(door_id)
|
|
502
|
+
if found is None: # pragma: no cover - just inserted
|
|
503
|
+
msg = "door row vanished"
|
|
504
|
+
raise RuntimeError(msg)
|
|
505
|
+
return found
|
|
506
|
+
|
|
507
|
+
def _drop_door(self, door_id: str) -> None:
|
|
508
|
+
"""Delete a door's key and row: the relay doesn't hold it, or it is over."""
|
|
509
|
+
self._doors.pop(door_id, None)
|
|
510
|
+
self._store.delete_door(door_id)
|
|
511
|
+
|
|
512
|
+
def _closing(self, door_id: str) -> None:
|
|
513
|
+
"""The key goes at once; the row stays until the relay confirms."""
|
|
514
|
+
self._doors.pop(door_id, None)
|
|
515
|
+
self._store.drop_door_secret(door_id, "closing")
|
|
516
|
+
|
|
517
|
+
async def _close_door(self, door: dict[str, Any]) -> bool:
|
|
518
|
+
"""Close a door at the relay. Its key is deleted at once, whatever the
|
|
519
|
+
relay says. Only success or ``door_not_open`` (unknown, expired or
|
|
520
|
+
closed already) counts as closed; any other answer (``auth_failed``
|
|
521
|
+
for a skewed clock, no answer) leaves it ``closing``, and the worker
|
|
522
|
+
tries again with a fresh signature."""
|
|
523
|
+
door_id = str(door["door_id"])
|
|
524
|
+
self._closing(door_id)
|
|
525
|
+
try:
|
|
526
|
+
await self._engine.rest.close_door(door_id)
|
|
527
|
+
closed = True
|
|
528
|
+
except RelayError as e:
|
|
529
|
+
closed = e.code == "door_not_open"
|
|
530
|
+
if closed:
|
|
531
|
+
self._store.delete_door(door_id)
|
|
532
|
+
self._close_retry.pop(door_id, None)
|
|
533
|
+
else:
|
|
534
|
+
# Tried again after a backoff (5 min doubling to an hour), not on
|
|
535
|
+
# every pass.
|
|
536
|
+
_, attempts = self._close_retry.get(door_id, (0, 0))
|
|
537
|
+
delay = min(RETRY_CAP_SECS, CLOSE_RETRY_SECS * 2 ** min(attempts, 16))
|
|
538
|
+
self._close_retry[door_id] = (self._now() + delay, attempts + 1)
|
|
539
|
+
return closed
|
|
540
|
+
|
|
541
|
+
def _close_due(self, door: dict[str, Any], now: int) -> bool:
|
|
542
|
+
"""Whether a ``closing`` door's next close attempt is due."""
|
|
543
|
+
due, _ = self._close_retry.get(str(door["door_id"]), (now, 0))
|
|
544
|
+
return str(door["state"]) != "closing" or due <= now
|
|
545
|
+
|
|
546
|
+
async def _free_door_slot(self, keep: str, row: dict[str, Any]) -> None:
|
|
547
|
+
"""The relay holds at most 4 open doors per mailbox: close the doors
|
|
548
|
+
no listing uses, or else the oldest retiring one."""
|
|
549
|
+
doors = [d for d in self._store.doors() if d["door_id"] != keep]
|
|
550
|
+
unused = [d for d in doors if d["state"] in (*PENDING_DOOR, "closing")]
|
|
551
|
+
if not unused:
|
|
552
|
+
retiring = [
|
|
553
|
+
d
|
|
554
|
+
for d in self._store.doors(row["directory"], row["name"])
|
|
555
|
+
if d["state"] == "retiring"
|
|
556
|
+
]
|
|
557
|
+
unused = sorted(retiring, key=lambda d: int(d["expires_at"]))[:1]
|
|
558
|
+
for d in unused:
|
|
559
|
+
await self._close_door(d)
|
|
560
|
+
|
|
561
|
+
async def _register(self, row: dict[str, Any], door: dict[str, Any]) -> dict[str, Any]:
|
|
562
|
+
"""Register a door at the relay. "Already exists" means an earlier
|
|
563
|
+
attempt got through (the id is ours and not yet public)."""
|
|
564
|
+
shortened = freed = False
|
|
565
|
+
while True:
|
|
566
|
+
try:
|
|
567
|
+
await self._engine.rest.create_door(str(door["door_id"]), int(door["expires_at"]))
|
|
568
|
+
except RelayError as e:
|
|
569
|
+
if e.code == "bad_request" and "already exists" in e.detail:
|
|
570
|
+
pass
|
|
571
|
+
elif e.code == "bad_request" and "expires_at" in e.detail and not shortened:
|
|
572
|
+
# The relay's clock is behind ours: ask for an hour less.
|
|
573
|
+
shortened = True
|
|
574
|
+
self._drop_door(str(door["door_id"]))
|
|
575
|
+
door = self._new_door(row, int(door["expires_at"]) - EXPIRY_SLACK_SECS)
|
|
576
|
+
continue
|
|
577
|
+
elif e.code == "quota_exceeded" and not freed:
|
|
578
|
+
freed = True
|
|
579
|
+
await self._free_door_slot(str(door["door_id"]), row)
|
|
580
|
+
continue
|
|
581
|
+
else:
|
|
582
|
+
raise
|
|
583
|
+
self._engine.failpoints.hit("door.after_register")
|
|
584
|
+
self._store.update_door(str(door["door_id"]), state="registered")
|
|
585
|
+
door["state"] = "registered"
|
|
586
|
+
return door
|
|
587
|
+
|
|
588
|
+
# -- one round: door, signature, post ---------------------------------------
|
|
589
|
+
|
|
590
|
+
def _sign(self, row: dict[str, Any], door: dict[str, Any]) -> ac.Listing:
|
|
591
|
+
now = self._now()
|
|
592
|
+
seq = max(int(row["max_signed_seq"]) + 1, now)
|
|
593
|
+
key = self._doors[str(door["door_id"])]
|
|
594
|
+
if row["state"] == "publishing":
|
|
595
|
+
return ac.sign_listing_content(
|
|
596
|
+
str(row["content"]).encode(),
|
|
597
|
+
self._engine.identity,
|
|
598
|
+
directory=str(row["directory"]),
|
|
599
|
+
relay=self._engine.config.relay_host,
|
|
600
|
+
seq=seq,
|
|
601
|
+
issued_at=now,
|
|
602
|
+
token_json=str(row["token"]).encode(),
|
|
603
|
+
door=key,
|
|
604
|
+
owner_domain=row["owner_domain"],
|
|
605
|
+
)
|
|
606
|
+
# A renewal: the human's listing, re-signed with only the door, seq
|
|
607
|
+
# and issued_at changed (SPEC 6.6.1, 13.2.2).
|
|
608
|
+
previous = ac.verify_listing(bytes(row["published"]))
|
|
609
|
+
return previous.renew(self._engine.identity, seq, max(now, previous.issued_at), key)
|
|
610
|
+
|
|
611
|
+
async def _round(self, row: dict[str, Any], *, interactive: bool) -> str:
|
|
612
|
+
"""Drive one publish or renewal as far as it goes now: ``published``,
|
|
613
|
+
``retrying`` (the worker goes on later), ``refused`` or ``waiting``."""
|
|
614
|
+
door = await self._door_for(row)
|
|
615
|
+
if door is None:
|
|
616
|
+
return "waiting"
|
|
617
|
+
if door["state"] == "registering":
|
|
618
|
+
registered = await self._registered(row, door, interactive=interactive)
|
|
619
|
+
if registered is None:
|
|
620
|
+
return "retrying"
|
|
621
|
+
door = registered
|
|
622
|
+
row = self._refresh(row)
|
|
623
|
+
if row["pending"] is None:
|
|
624
|
+
signed = self._sign(row, door)
|
|
625
|
+
self._store.update_listing(
|
|
626
|
+
row["directory"],
|
|
627
|
+
row["name"],
|
|
628
|
+
max_signed_seq=signed.seq,
|
|
629
|
+
pending=signed.bytes,
|
|
630
|
+
pending_door=door["door_id"],
|
|
631
|
+
updated_at=self._now(),
|
|
632
|
+
)
|
|
633
|
+
self._engine.failpoints.hit("listing.after_sign")
|
|
634
|
+
row = self._refresh(row)
|
|
635
|
+
return await self._post(row, door, interactive=interactive)
|
|
636
|
+
|
|
637
|
+
async def _door_for(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
|
638
|
+
"""The door this round publishes: the one waiting, or a fresh one;
|
|
639
|
+
None when the token has too little life left to renew."""
|
|
640
|
+
now = self._now()
|
|
641
|
+
door = self._pending_door(row)
|
|
642
|
+
if door is None:
|
|
643
|
+
if row["state"] != "publishing" and int(row["token_expires_at"]) - now < (
|
|
644
|
+
MIN_TOKEN_LEFT_SECS
|
|
645
|
+
):
|
|
646
|
+
# The door runs out with the token; a new token is a new publish.
|
|
647
|
+
self._store.update_listing(
|
|
648
|
+
row["directory"], row["name"], next_renewal_at=None, retry_at=None
|
|
649
|
+
)
|
|
650
|
+
return None
|
|
651
|
+
return self._new_door(row)
|
|
652
|
+
made, expires = int(door["created_at"]), int(door["expires_at"])
|
|
653
|
+
if now - made > (expires - made) // 2:
|
|
654
|
+
# Half the waiting door's life went by without the relay or the
|
|
655
|
+
# directory: a fresh door (and signature), so a listing never
|
|
656
|
+
# carries a door about to expire, and at most three are open
|
|
657
|
+
# (current, retiring and this one).
|
|
658
|
+
await self._discard_pending(row, door)
|
|
659
|
+
return self._new_door(row)
|
|
660
|
+
return door
|
|
661
|
+
|
|
662
|
+
async def _registered(
|
|
663
|
+
self, row: dict[str, Any], door: dict[str, Any], *, interactive: bool
|
|
664
|
+
) -> dict[str, Any] | None:
|
|
665
|
+
"""The door, registered; None when the worker tries again later."""
|
|
666
|
+
try:
|
|
667
|
+
return await self._register(row, door)
|
|
668
|
+
except RelayError as e:
|
|
669
|
+
if not interactive:
|
|
670
|
+
await self._retry_later(row, e.code)
|
|
671
|
+
return None
|
|
672
|
+
# The relay may hold the door if its answer was lost: keep the
|
|
673
|
+
# row until it confirms the close.
|
|
674
|
+
door_id = str(door["door_id"])
|
|
675
|
+
if e.code in NOT_REGISTERED:
|
|
676
|
+
self._drop_door(door_id)
|
|
677
|
+
else:
|
|
678
|
+
self._closing(door_id)
|
|
679
|
+
await self._restore(row, e.code)
|
|
680
|
+
msg = f"the relay didn't register the door ({e.code}); nothing was published"
|
|
681
|
+
raise ApiError(502, "relay", msg) from None
|
|
682
|
+
|
|
683
|
+
async def _post(self, row: dict[str, Any], door: dict[str, Any], *, interactive: bool) -> str:
|
|
684
|
+
client: DirectoryClient | None = None
|
|
685
|
+
try:
|
|
686
|
+
client = self._client(row)
|
|
687
|
+
answer = await client.publish(bytes(row["pending"]))
|
|
688
|
+
except DirectoryError as e:
|
|
689
|
+
if client is not None and e.maybe_accepted and await self._landed(client, row):
|
|
690
|
+
answer = {} # it is listed as signed; the answer just can't be read
|
|
691
|
+
elif (e.transient or e.maybe_accepted) and not (interactive and e.code == "blocked"):
|
|
692
|
+
await self._retry_later(row, e.code)
|
|
693
|
+
return "retrying"
|
|
694
|
+
else:
|
|
695
|
+
return await self._refusal(row, door, e, interactive)
|
|
696
|
+
self._engine.failpoints.hit("listing.after_post")
|
|
697
|
+
await self._published(row, door, answer)
|
|
698
|
+
return "published"
|
|
699
|
+
|
|
700
|
+
async def _landed(self, client: DirectoryClient, row: dict[str, Any]) -> bool:
|
|
701
|
+
"""Whether the directory lists exactly the bytes posted (it answered
|
|
702
|
+
with a 2xx that couldn't be read)."""
|
|
703
|
+
try:
|
|
704
|
+
return await client.fetch_listing(str(row["name"])) == bytes(row["pending"])
|
|
705
|
+
except DirectoryError:
|
|
706
|
+
return False
|
|
707
|
+
|
|
708
|
+
async def _discard_pending(self, row: dict[str, Any], door: dict[str, Any]) -> None:
|
|
709
|
+
await self._close_door(door)
|
|
710
|
+
self._store.update_listing(row["directory"], row["name"], pending=None, pending_door=None)
|
|
711
|
+
row["pending"] = row["pending_door"] = None
|
|
712
|
+
|
|
713
|
+
async def _refusal(
|
|
714
|
+
self, row: dict[str, Any], door: dict[str, Any], e: DirectoryError, interactive: bool
|
|
715
|
+
) -> str:
|
|
716
|
+
"""A refusal (of known bytes; never an unreadable 2xx). A publish is
|
|
717
|
+
put back as it was. A renewal stops only for the SPEC's reasons;
|
|
718
|
+
otherwise it is tried again with a fresh door and signature."""
|
|
719
|
+
LOG.warning("listing_refused", code=e.code)
|
|
720
|
+
if row["state"] != "publishing" and e.code not in STOP_RENEWING:
|
|
721
|
+
await self._discard_pending(row, door)
|
|
722
|
+
await self._retry_later(row, e.code, report=True)
|
|
723
|
+
return "retrying"
|
|
724
|
+
await self._close_door(door)
|
|
725
|
+
if row["state"] == "publishing":
|
|
726
|
+
await self._restore(row, e.code)
|
|
727
|
+
else:
|
|
728
|
+
await self._stop_renewing(row, e.code)
|
|
729
|
+
if not interactive:
|
|
730
|
+
return "refused"
|
|
731
|
+
status = REFUSAL_STATUS.get(e.code)
|
|
732
|
+
message = f"the directory refused the listing ({e.code})"
|
|
733
|
+
if e.detail:
|
|
734
|
+
message += f": {clean_detail(e.detail)}"
|
|
735
|
+
if status is None:
|
|
736
|
+
raise ApiError(502, "directory", message)
|
|
737
|
+
raise ApiError(status, e.code, message)
|
|
738
|
+
|
|
739
|
+
async def _published(
|
|
740
|
+
self, row: dict[str, Any], door: dict[str, Any], answer: dict[str, Any]
|
|
741
|
+
) -> None:
|
|
742
|
+
listing = ac.verify_listing(bytes(row["pending"]))
|
|
743
|
+
host, name, now = str(row["directory"]), str(row["name"]), self._now()
|
|
744
|
+
door_id, expires_at = str(door["door_id"]), int(door["expires_at"])
|
|
745
|
+
renews_at = listing.issued_at + (expires_at - listing.issued_at) // 2
|
|
746
|
+
reported = owner_state(answer)
|
|
747
|
+
renewal = row["state"] != "publishing"
|
|
748
|
+
|
|
749
|
+
def effects() -> None:
|
|
750
|
+
for d in self._store.doors(host, name):
|
|
751
|
+
if d["state"] == "current" and d["door_id"] != door_id:
|
|
752
|
+
self._store.update_door(d["door_id"], state="retiring")
|
|
753
|
+
self._store.update_door(door_id, state="current")
|
|
754
|
+
self._store.update_listing(
|
|
755
|
+
host,
|
|
756
|
+
name,
|
|
757
|
+
state="published",
|
|
758
|
+
published=listing.bytes,
|
|
759
|
+
published_sha256=listing.sha256.hex(),
|
|
760
|
+
published_at=now,
|
|
761
|
+
seq=listing.seq,
|
|
762
|
+
pending=None,
|
|
763
|
+
pending_door=None,
|
|
764
|
+
next_renewal_at=renews_at,
|
|
765
|
+
owner_state=reported if reported is not None else row["owner_state"],
|
|
766
|
+
retry_at=None,
|
|
767
|
+
attempts=0,
|
|
768
|
+
failing_since=None,
|
|
769
|
+
stale_since=None,
|
|
770
|
+
last_error=None,
|
|
771
|
+
restore_state=None,
|
|
772
|
+
restore_renewal_at=None,
|
|
773
|
+
updated_at=now,
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
kind = "listing_renewed" if renewal else "listing_published"
|
|
777
|
+
data: dict[str, Any] = {
|
|
778
|
+
"directory": host,
|
|
779
|
+
"name": name,
|
|
780
|
+
"seq": listing.seq,
|
|
781
|
+
"door_expires_at": expires_at,
|
|
782
|
+
}
|
|
783
|
+
if not renewal:
|
|
784
|
+
data |= {"url": listing.url, "token_expires_at": listing.token_expires_at}
|
|
785
|
+
await self._commit(effects, [(kind, f"{kind}/{host}/{name}/{listing.seq}", data)])
|
|
786
|
+
LOG.info(kind)
|
|
787
|
+
|
|
788
|
+
def _restored(self, row: dict[str, Any]) -> dict[str, Any]:
|
|
789
|
+
"""The listing as it was before this publish began (``restore_state``):
|
|
790
|
+
a refused publish never lists anything the human didn't publish
|
|
791
|
+
again, and a withdrawn, expired or stale listing stays so."""
|
|
792
|
+
state = row["restore_state"] or "refused"
|
|
793
|
+
fields: dict[str, Any] = {
|
|
794
|
+
"state": state,
|
|
795
|
+
"next_renewal_at": row["restore_renewal_at"] if state in LIVE else None,
|
|
796
|
+
"restore_state": None,
|
|
797
|
+
"restore_renewal_at": None,
|
|
798
|
+
}
|
|
799
|
+
if row["published"] is not None:
|
|
800
|
+
previous = ac.verify_listing(bytes(row["published"]))
|
|
801
|
+
params = json.loads(previous.bytes)["capabilities"]["extensions"][0]["params"]
|
|
802
|
+
fields |= {
|
|
803
|
+
"content": previous.content.decode(),
|
|
804
|
+
"token": canonical(params["token"]).decode(),
|
|
805
|
+
"token_expires_at": previous.token_expires_at,
|
|
806
|
+
"owner_domain": previous.owner_domain,
|
|
807
|
+
"seq": previous.seq,
|
|
808
|
+
}
|
|
809
|
+
return fields
|
|
810
|
+
|
|
811
|
+
async def _restore(self, row: dict[str, Any], code: str) -> None:
|
|
812
|
+
now = self._now()
|
|
813
|
+
fields = self._restored(self._refresh(row)) | {
|
|
814
|
+
"pending": None,
|
|
815
|
+
"pending_door": None,
|
|
816
|
+
"retry_at": None,
|
|
817
|
+
"attempts": 0,
|
|
818
|
+
"failing_since": None,
|
|
819
|
+
"last_error": _error(code, now),
|
|
820
|
+
"updated_at": now,
|
|
821
|
+
}
|
|
822
|
+
await self._commit(
|
|
823
|
+
lambda: self._store.update_listing(row["directory"], row["name"], **fields), []
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
async def _stop_renewing(self, row: dict[str, Any], code: str) -> None:
|
|
827
|
+
"""A renewal refused for good: the current door keeps working until it
|
|
828
|
+
expires, and the human is told."""
|
|
829
|
+
host, name, now = str(row["directory"]), str(row["name"]), self._now()
|
|
830
|
+
since = row["stale_since"] if row["stale_since"] is not None else now
|
|
831
|
+
fields = {
|
|
832
|
+
"state": "stale",
|
|
833
|
+
"pending": None,
|
|
834
|
+
"pending_door": None,
|
|
835
|
+
"next_renewal_at": None,
|
|
836
|
+
"retry_at": None,
|
|
837
|
+
"attempts": 0,
|
|
838
|
+
"failing_since": None,
|
|
839
|
+
"stale_since": since,
|
|
840
|
+
"last_error": _error(code, now),
|
|
841
|
+
"updated_at": now,
|
|
842
|
+
}
|
|
843
|
+
data = {
|
|
844
|
+
"directory": host,
|
|
845
|
+
"name": name,
|
|
846
|
+
"reason": "refused",
|
|
847
|
+
"code": code,
|
|
848
|
+
"since": since,
|
|
849
|
+
"door_expires_at": self._current_expiry(host, name),
|
|
850
|
+
}
|
|
851
|
+
await self._commit(
|
|
852
|
+
lambda: self._store.update_listing(host, name, **fields),
|
|
853
|
+
[("listing_stale", f"listing_stale/{host}/{name}/refused/{since}", data)],
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
def _current_expiry(self, host: str, name: str) -> int | None:
|
|
857
|
+
current = [d for d in self._store.doors(host, name) if d["state"] == "current"]
|
|
858
|
+
return int(current[-1]["expires_at"]) if current else None
|
|
859
|
+
|
|
860
|
+
async def _retry_later(self, row: dict[str, Any], code: str, *, report: bool = False) -> None:
|
|
861
|
+
"""No answer, or not now: the same request goes again after a backoff
|
|
862
|
+
(60 s doubling to an hour). A renewal failing for an hour (or at once,
|
|
863
|
+
with ``report``, for a refusal) makes the listing ``stale``; the old
|
|
864
|
+
door keeps working until it expires. A withdrawal failing for an hour
|
|
865
|
+
is reported, and after 7 days it is given up (``withdraw_failed``)."""
|
|
866
|
+
host, name, now = str(row["directory"]), str(row["name"]), self._now()
|
|
867
|
+
attempts = int(row["attempts"]) + 1
|
|
868
|
+
delay = min(RETRY_CAP_SECS, RETRY_BASE_SECS * 2 ** min(attempts - 1, 16))
|
|
869
|
+
failing_since = row["failing_since"] if row["failing_since"] is not None else now
|
|
870
|
+
fields: dict[str, Any] = {
|
|
871
|
+
"retry_at": now + delay,
|
|
872
|
+
"attempts": attempts,
|
|
873
|
+
"failing_since": failing_since,
|
|
874
|
+
"last_error": _error(code, now),
|
|
875
|
+
"updated_at": now,
|
|
876
|
+
}
|
|
877
|
+
events = []
|
|
878
|
+
failing = now - failing_since
|
|
879
|
+
if row["state"] in ("published", "stale") and (report or failing >= STALE_AFTER_SECS):
|
|
880
|
+
since = row["stale_since"] if row["stale_since"] is not None else now
|
|
881
|
+
fields |= {"state": "stale", "stale_since": since}
|
|
882
|
+
data = {
|
|
883
|
+
"directory": host,
|
|
884
|
+
"name": name,
|
|
885
|
+
"reason": "publish_failed",
|
|
886
|
+
"code": code,
|
|
887
|
+
"since": since,
|
|
888
|
+
"door_expires_at": self._current_expiry(host, name),
|
|
889
|
+
}
|
|
890
|
+
cause = f"listing_stale/{host}/{name}/publish_failed/{failing_since}"
|
|
891
|
+
events.append(("listing_stale", cause, data))
|
|
892
|
+
if row["state"] == "withdrawing" and failing >= STALE_AFTER_SECS:
|
|
893
|
+
data = {"directory": host, "name": name, "code": code, "since": failing_since}
|
|
894
|
+
if failing >= WITHDRAW_GIVE_UP_SECS:
|
|
895
|
+
fields |= {"state": "withdraw_failed", "retry_at": None}
|
|
896
|
+
cause = f"listing_withdraw_failed/{host}/{name}/{failing_since}"
|
|
897
|
+
events.append(("listing_withdraw_failed", cause, data))
|
|
898
|
+
else:
|
|
899
|
+
cause = f"listing_withdraw_pending/{host}/{name}/{failing_since}"
|
|
900
|
+
events.append(("listing_withdraw_pending", cause, data))
|
|
901
|
+
LOG.info("listing_retry", code=code, attempts=attempts)
|
|
902
|
+
await self._commit(lambda: self._store.update_listing(host, name, **fields), events)
|
|
903
|
+
|
|
904
|
+
# -- withdraw (either token; forcing it is the human's) ------------------------
|
|
905
|
+
|
|
906
|
+
def _withdrawable(self) -> dict[str, Any] | None:
|
|
907
|
+
for row in self._store.listings():
|
|
908
|
+
if row["state"] in WITHDRAWABLE:
|
|
909
|
+
return row
|
|
910
|
+
# A refused publish may still be listed: something was signed.
|
|
911
|
+
if row["state"] == "refused" and int(row["max_signed_seq"]) > 0:
|
|
912
|
+
return row
|
|
913
|
+
return None
|
|
914
|
+
|
|
915
|
+
async def withdraw(self, *, force: bool = False, human: bool = False) -> dict[str, Any]:
|
|
916
|
+
"""Close every door at the relay (keys deleted at once), then send
|
|
917
|
+
the signed withdrawal. Allowed to the agent: it only reduces
|
|
918
|
+
exposure (SPEC 13.2.3). ``force`` (the human only, after a
|
|
919
|
+
withdrawal the directory hasn't taken) ends it here only:
|
|
920
|
+
``withdrawn_locally``, and the directory may go on listing it."""
|
|
921
|
+
if force and not human:
|
|
922
|
+
raise ApiError(403, "human_only", "forcing a withdrawal needs the human token")
|
|
923
|
+
async with self._lock:
|
|
924
|
+
row = self._withdrawable()
|
|
925
|
+
if row is None:
|
|
926
|
+
raise ApiError(404, "not_found", "there is no listing to withdraw")
|
|
927
|
+
if force and row["state"] not in FORCEABLE:
|
|
928
|
+
msg = "force only ends a withdrawal the directory hasn't taken; withdraw first"
|
|
929
|
+
raise ApiError(409, "not_withdrawing", msg)
|
|
930
|
+
if row["state"] != "withdrawing":
|
|
931
|
+
self._store.update_listing(
|
|
932
|
+
row["directory"],
|
|
933
|
+
row["name"],
|
|
934
|
+
state="withdrawing",
|
|
935
|
+
pending=None,
|
|
936
|
+
pending_door=None,
|
|
937
|
+
next_renewal_at=None,
|
|
938
|
+
retry_at=None,
|
|
939
|
+
attempts=0,
|
|
940
|
+
failing_since=None,
|
|
941
|
+
restore_state=None,
|
|
942
|
+
restore_renewal_at=None,
|
|
943
|
+
updated_at=self._now(),
|
|
944
|
+
)
|
|
945
|
+
answer = await self._withdraw_round(self._refresh(row), force=force, now=True)
|
|
946
|
+
self._wake.set()
|
|
947
|
+
if answer.pop("unknown_directory", False):
|
|
948
|
+
msg = (
|
|
949
|
+
"the doors are closed, but the directory is no longer configured, so the "
|
|
950
|
+
"withdrawal can't be sent: add its [[directory]] table back, or force it"
|
|
951
|
+
)
|
|
952
|
+
raise ApiError(400, "unknown_directory", msg)
|
|
953
|
+
return answer
|
|
954
|
+
|
|
955
|
+
async def _withdraw_round(
|
|
956
|
+
self, row: dict[str, Any], *, force: bool = False, now: bool = False
|
|
957
|
+
) -> dict[str, Any]:
|
|
958
|
+
"""One attempt. ``now`` (a request, not the worker) tries every door
|
|
959
|
+
again, whatever its close backoff."""
|
|
960
|
+
host, name = str(row["directory"]), str(row["name"])
|
|
961
|
+
closed = 0
|
|
962
|
+
for door in self._store.doors(host, name):
|
|
963
|
+
if now or self._close_due(door, self._now()):
|
|
964
|
+
closed += await self._close_door(door)
|
|
965
|
+
pending = len(self._store.doors(host, name))
|
|
966
|
+
self._engine.failpoints.hit("withdraw.after_close")
|
|
967
|
+
seq = int(row["max_signed_seq"])
|
|
968
|
+
answer = {
|
|
969
|
+
"directory": host,
|
|
970
|
+
"name": name,
|
|
971
|
+
"seq": seq,
|
|
972
|
+
"state": "withdrawing",
|
|
973
|
+
"doors_closed": closed,
|
|
974
|
+
"doors_close_pending": pending,
|
|
975
|
+
"forced": force,
|
|
976
|
+
}
|
|
977
|
+
error: str | None = "forced" if force else None
|
|
978
|
+
if seq > 0 and not force: # something was signed, so something may be listed
|
|
979
|
+
try:
|
|
980
|
+
seq = await self._send_withdrawal(row)
|
|
981
|
+
except DirectoryError as e:
|
|
982
|
+
if e.transient or e.code in ("stale_ts", "unknown_directory"):
|
|
983
|
+
# Sent again with a fresh `ts` (300 s skew at the directory).
|
|
984
|
+
await self._retry_later(row, e.code)
|
|
985
|
+
if e.code == "unknown_directory":
|
|
986
|
+
answer["unknown_directory"] = True
|
|
987
|
+
return answer
|
|
988
|
+
# not_found: nothing was listed. wrong_identity and the rest:
|
|
989
|
+
# the directory won't take a withdrawal from this identity.
|
|
990
|
+
error = None if e.code == "not_found" else e.code
|
|
991
|
+
state = "withdrawn_locally" if force else "withdrawn"
|
|
992
|
+
at = self._now()
|
|
993
|
+
fields: dict[str, Any] = {
|
|
994
|
+
"state": state,
|
|
995
|
+
"retry_at": None,
|
|
996
|
+
"attempts": 0,
|
|
997
|
+
"failing_since": None,
|
|
998
|
+
"updated_at": at,
|
|
999
|
+
}
|
|
1000
|
+
if error is not None:
|
|
1001
|
+
fields["last_error"] = _error(error, at)
|
|
1002
|
+
data = {
|
|
1003
|
+
"directory": host,
|
|
1004
|
+
"name": name,
|
|
1005
|
+
"seq": seq,
|
|
1006
|
+
"doors_close_pending": pending,
|
|
1007
|
+
"forced": force,
|
|
1008
|
+
}
|
|
1009
|
+
await self._commit(
|
|
1010
|
+
lambda: self._store.update_listing(host, name, **fields),
|
|
1011
|
+
[("listing_withdrawn", f"listing_withdrawn/{host}/{name}/{seq}/{state}", data)],
|
|
1012
|
+
)
|
|
1013
|
+
LOG.info("listing_withdrawn", doors_close_pending=pending, forced=force)
|
|
1014
|
+
return answer | {"seq": seq, "state": state}
|
|
1015
|
+
|
|
1016
|
+
async def _send_withdrawal(self, row: dict[str, Any]) -> int:
|
|
1017
|
+
"""Withdraw everything up to ``max_signed_seq`` (a publish whose answer
|
|
1018
|
+
was lost may have landed). ``stale_seq`` (a database restored from a
|
|
1019
|
+
backup) is retried once at the seq the directory lists, if that
|
|
1020
|
+
listing is this identity's."""
|
|
1021
|
+
client = self._client(row)
|
|
1022
|
+
host, name = str(row["directory"]), str(row["name"])
|
|
1023
|
+
seq = int(row["max_signed_seq"])
|
|
1024
|
+
identity = self._engine.identity
|
|
1025
|
+
try:
|
|
1026
|
+
await client.withdraw(ac.sign_withdrawal(identity, host, name, seq, self._now()))
|
|
1027
|
+
except DirectoryError as e:
|
|
1028
|
+
if e.code != "stale_seq":
|
|
1029
|
+
raise
|
|
1030
|
+
listed = await self._listed_seq(client, host, name)
|
|
1031
|
+
if listed is None or listed <= seq:
|
|
1032
|
+
raise
|
|
1033
|
+
seq = listed
|
|
1034
|
+
self._store.update_listing(host, name, max_signed_seq=seq)
|
|
1035
|
+
await client.withdraw(ac.sign_withdrawal(identity, host, name, seq, self._now()))
|
|
1036
|
+
return seq
|
|
1037
|
+
|
|
1038
|
+
async def _listed_seq(self, client: DirectoryClient, host: str, name: str) -> int | None:
|
|
1039
|
+
"""The ``seq`` the directory lists under ``name``, if it is a valid
|
|
1040
|
+
listing of this identity for this directory."""
|
|
1041
|
+
try:
|
|
1042
|
+
listing = ac.verify_listing(await client.fetch_listing(name))
|
|
1043
|
+
except (DirectoryError, ac.AgentConnectError):
|
|
1044
|
+
return None
|
|
1045
|
+
same = (listing.identity, listing.directory, listing.name) == (self._engine.me, host, name)
|
|
1046
|
+
return listing.seq if same else None
|
|
1047
|
+
|
|
1048
|
+
# -- status (either token) ---------------------------------------------------
|
|
1049
|
+
|
|
1050
|
+
def _latest(self, states: frozenset[str]) -> dict[str, Any] | None:
|
|
1051
|
+
return next((r for r in self._store.listings() if r["state"] in states), None)
|
|
1052
|
+
|
|
1053
|
+
def status(self) -> dict[str, Any]:
|
|
1054
|
+
"""The runtime's listing as it stands: the one it holds, else the
|
|
1055
|
+
latest one in any state. No door key is read."""
|
|
1056
|
+
rows = self._store.listings()
|
|
1057
|
+
if not rows:
|
|
1058
|
+
return {"listing": None}
|
|
1059
|
+
row, now = self._latest(HOLDING) or rows[0], self._now()
|
|
1060
|
+
doors = self._store.doors(row["directory"], row["name"])
|
|
1061
|
+
current = next((d for d in reversed(doors) if d["state"] == "current"), None)
|
|
1062
|
+
door = None
|
|
1063
|
+
if current is not None:
|
|
1064
|
+
expires = int(current["expires_at"])
|
|
1065
|
+
door = {
|
|
1066
|
+
"expires_at": expires,
|
|
1067
|
+
"renews_at": row["next_renewal_at"],
|
|
1068
|
+
"expiring": expires - now < DOOR_WARNING_SECS,
|
|
1069
|
+
}
|
|
1070
|
+
error = json.loads(row["last_error"]) if row["last_error"] else None
|
|
1071
|
+
retrying = None
|
|
1072
|
+
if row["state"] in ("publishing", "withdrawing") and row["failing_since"] is not None:
|
|
1073
|
+
retrying = {
|
|
1074
|
+
"since": int(row["failing_since"]),
|
|
1075
|
+
"code": None if error is None else error["code"],
|
|
1076
|
+
}
|
|
1077
|
+
expires_at = int(row["token_expires_at"])
|
|
1078
|
+
return {
|
|
1079
|
+
"listing": {
|
|
1080
|
+
"directory": row["directory"],
|
|
1081
|
+
"name": row["name"],
|
|
1082
|
+
"url": _url(row),
|
|
1083
|
+
"state": row["state"],
|
|
1084
|
+
"seq": int(row["seq"]),
|
|
1085
|
+
"published_at": row["published_at"],
|
|
1086
|
+
"identity": self._engine.me,
|
|
1087
|
+
"fingerprint": self._engine.identity.fingerprint,
|
|
1088
|
+
"door": door,
|
|
1089
|
+
"doors_open": len(
|
|
1090
|
+
[d for d in doors if d["state"] in OPEN_DOOR and int(d["expires_at"]) > now]
|
|
1091
|
+
),
|
|
1092
|
+
"token": {"expires_at": expires_at, "warn_at": expires_at - TOKEN_WARNING_SECS},
|
|
1093
|
+
"owner_domain": row["owner_domain"],
|
|
1094
|
+
"owner_state": row["owner_state"],
|
|
1095
|
+
"last_error": error,
|
|
1096
|
+
"retrying": retrying,
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
# -- the discovery worker ---------------------------------------------------
|
|
1101
|
+
|
|
1102
|
+
async def run(self) -> None:
|
|
1103
|
+
"""Renew, retry, expire and delete keys when each is due; a pass that
|
|
1104
|
+
fails is logged by type and retried after a backoff."""
|
|
1105
|
+
backoff = Backoff(base=1.0, cap=300.0, rng=self._engine.options.backoff_rng)
|
|
1106
|
+
busy = 0
|
|
1107
|
+
while True:
|
|
1108
|
+
self._wake.clear()
|
|
1109
|
+
try:
|
|
1110
|
+
due = await self.step()
|
|
1111
|
+
backoff.reset()
|
|
1112
|
+
except Exception as e:
|
|
1113
|
+
LOG.warning("discovery_step_failed", error=type(e).__name__)
|
|
1114
|
+
due = self._now() + backoff.next()
|
|
1115
|
+
# Work due at once runs at once, but a pass that makes no progress
|
|
1116
|
+
# can't spin: after a few in a row, the worker pauses a second.
|
|
1117
|
+
busy = busy + 1 if due is not None and due <= self._now() else 0
|
|
1118
|
+
if busy > MAX_IDLE_PASSES:
|
|
1119
|
+
due, busy = self._now() + 1, 0
|
|
1120
|
+
await self._wait(due)
|
|
1121
|
+
|
|
1122
|
+
async def _wait(self, due: float | None) -> None:
|
|
1123
|
+
"""Until ``due`` (Unix seconds on the runtime's clock) or a wake-up.
|
|
1124
|
+
One timer, set against the clock itself (``Clock.sleep_until``), so
|
|
1125
|
+
neither a busy loop nor a test moving a fake clock re-arms it."""
|
|
1126
|
+
woken = asyncio.ensure_future(self._wake.wait())
|
|
1127
|
+
waits: set[asyncio.Future[Any]] = {woken}
|
|
1128
|
+
if due is not None:
|
|
1129
|
+
# A deadline, not a retry pause: the fake clock doesn't count it.
|
|
1130
|
+
waits.add(asyncio.ensure_future(self._engine.clock.sleep_until(due, deadline=True)))
|
|
1131
|
+
try:
|
|
1132
|
+
await asyncio.sleep(0) # the timer is set before anyone is told
|
|
1133
|
+
self._engine._pulse() # a pass ended: waiters re-check
|
|
1134
|
+
await asyncio.wait(waits, return_when=asyncio.FIRST_COMPLETED)
|
|
1135
|
+
finally:
|
|
1136
|
+
for w in waits:
|
|
1137
|
+
w.cancel()
|
|
1138
|
+
|
|
1139
|
+
async def step(self) -> float | None:
|
|
1140
|
+
"""One pass over the doors and listings. Returns when the next thing
|
|
1141
|
+
is due (Unix seconds; at or before now when it is overdue), or None
|
|
1142
|
+
when nothing is scheduled."""
|
|
1143
|
+
async with self._lock:
|
|
1144
|
+
await self._sweep_doors()
|
|
1145
|
+
for row in self._store.listings():
|
|
1146
|
+
await self._tend(row)
|
|
1147
|
+
return self._next_due()
|
|
1148
|
+
|
|
1149
|
+
async def _sweep_doors(self) -> None:
|
|
1150
|
+
now = self._now()
|
|
1151
|
+
for door in self._store.doors():
|
|
1152
|
+
door_id, expires_at = str(door["door_id"]), int(door["expires_at"])
|
|
1153
|
+
if door["state"] == "closing":
|
|
1154
|
+
if now >= expires_at: # the relay forgets it anyway
|
|
1155
|
+
self._store.delete_door(door_id)
|
|
1156
|
+
self._close_retry.pop(door_id, None)
|
|
1157
|
+
elif self._close_due(door, now):
|
|
1158
|
+
await self._close_door(door)
|
|
1159
|
+
continue
|
|
1160
|
+
# SPEC 6.6.1: the key goes an hour after the door expires, once
|
|
1161
|
+
# every knock it could have received has lapsed.
|
|
1162
|
+
if now >= expires_at + ac.DOOR_GRACE_SECS:
|
|
1163
|
+
self._drop_door(door_id)
|
|
1164
|
+
LOG.info("door_key_deleted")
|
|
1165
|
+
|
|
1166
|
+
async def _tend(self, row: dict[str, Any]) -> None:
|
|
1167
|
+
now, state = self._now(), row["state"]
|
|
1168
|
+
if state == "withdrawing":
|
|
1169
|
+
if _due(row["retry_at"], now):
|
|
1170
|
+
await self._withdraw_round(row)
|
|
1171
|
+
return
|
|
1172
|
+
if state not in LIVE:
|
|
1173
|
+
return
|
|
1174
|
+
host, name = str(row["directory"]), str(row["name"])
|
|
1175
|
+
expires_at = int(row["token_expires_at"])
|
|
1176
|
+
if now >= expires_at:
|
|
1177
|
+
await self._expire(row)
|
|
1178
|
+
return
|
|
1179
|
+
if not row["token_warned"] and now >= expires_at - TOKEN_WARNING_SECS:
|
|
1180
|
+
await self._commit(
|
|
1181
|
+
lambda: self._store.update_listing(host, name, token_warned=1),
|
|
1182
|
+
[
|
|
1183
|
+
(
|
|
1184
|
+
"listing_token_expiring",
|
|
1185
|
+
f"listing_token_expiring/{host}/{name}/{expires_at}",
|
|
1186
|
+
{"directory": host, "name": name, "token_expires_at": expires_at},
|
|
1187
|
+
)
|
|
1188
|
+
],
|
|
1189
|
+
)
|
|
1190
|
+
await self._door_checks(row)
|
|
1191
|
+
row = self._refresh(row)
|
|
1192
|
+
if not _due(row["retry_at"], now):
|
|
1193
|
+
return
|
|
1194
|
+
renew_at = row["next_renewal_at"]
|
|
1195
|
+
if row["state"] == "publishing" or (renew_at is not None and now >= renew_at):
|
|
1196
|
+
await self._round(row, interactive=False)
|
|
1197
|
+
|
|
1198
|
+
async def _door_checks(self, row: dict[str, Any]) -> None:
|
|
1199
|
+
"""Tell the human when the listed door is about to expire while a
|
|
1200
|
+
republish waits, and when it expired before a renewal landed."""
|
|
1201
|
+
host, name, now = str(row["directory"]), str(row["name"]), self._now()
|
|
1202
|
+
current = [d for d in self._store.doors(host, name) if d["state"] == "current"]
|
|
1203
|
+
if not current:
|
|
1204
|
+
return
|
|
1205
|
+
door_id, expires = current[-1]["door_id"], int(current[-1]["expires_at"])
|
|
1206
|
+
publishing = row["state"] == "publishing"
|
|
1207
|
+
since = row["stale_since"] if row["stale_since"] is not None else now
|
|
1208
|
+
data = {"directory": host, "name": name, "since": since, "door_expires_at": expires}
|
|
1209
|
+
if expires <= now:
|
|
1210
|
+
# A republish that waits stays `publishing`; the human is told all the same.
|
|
1211
|
+
fields = {} if publishing else {"state": "stale", "stale_since": since}
|
|
1212
|
+
await self._commit(
|
|
1213
|
+
lambda: self._store.update_listing(host, name, updated_at=now, **fields),
|
|
1214
|
+
[
|
|
1215
|
+
(
|
|
1216
|
+
"listing_stale",
|
|
1217
|
+
f"listing_stale/{host}/{name}/door_expired/{door_id}",
|
|
1218
|
+
data | {"reason": "door_expired"},
|
|
1219
|
+
)
|
|
1220
|
+
],
|
|
1221
|
+
)
|
|
1222
|
+
elif publishing and expires - now < DOOR_WARNING_SECS:
|
|
1223
|
+
await self._commit(
|
|
1224
|
+
lambda: None,
|
|
1225
|
+
[
|
|
1226
|
+
(
|
|
1227
|
+
"listing_stale",
|
|
1228
|
+
f"listing_stale/{host}/{name}/door_expiring/{door_id}",
|
|
1229
|
+
data | {"reason": "door_expiring"},
|
|
1230
|
+
)
|
|
1231
|
+
],
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
async def _expire(self, row: dict[str, Any]) -> None:
|
|
1235
|
+
"""The token expired: the listing counts as withdrawn everywhere
|
|
1236
|
+
(SPEC 13.2.1). Its doors are closed and their keys deleted; a new
|
|
1237
|
+
token is a new publish."""
|
|
1238
|
+
host, name, now = str(row["directory"]), str(row["name"]), self._now()
|
|
1239
|
+
for door in self._store.doors(host, name):
|
|
1240
|
+
await self._close_door(door)
|
|
1241
|
+
fields = {
|
|
1242
|
+
"state": "expired",
|
|
1243
|
+
"pending": None,
|
|
1244
|
+
"pending_door": None,
|
|
1245
|
+
"next_renewal_at": None,
|
|
1246
|
+
"retry_at": None,
|
|
1247
|
+
"attempts": 0,
|
|
1248
|
+
"failing_since": None,
|
|
1249
|
+
"restore_state": None,
|
|
1250
|
+
"restore_renewal_at": None,
|
|
1251
|
+
"updated_at": now,
|
|
1252
|
+
}
|
|
1253
|
+
await self._commit(
|
|
1254
|
+
lambda: self._store.update_listing(host, name, **fields),
|
|
1255
|
+
[
|
|
1256
|
+
(
|
|
1257
|
+
"listing_token_expired",
|
|
1258
|
+
f"listing_token_expired/{host}/{name}/{row['token_expires_at']}",
|
|
1259
|
+
{"directory": host, "name": name},
|
|
1260
|
+
)
|
|
1261
|
+
],
|
|
1262
|
+
)
|
|
1263
|
+
LOG.info("listing_token_expired")
|
|
1264
|
+
|
|
1265
|
+
def _next_due(self) -> float | None:
|
|
1266
|
+
"""When the next thing is due. Overdue work (the clock moved during
|
|
1267
|
+
the pass) counts, so it runs at once; what this pass already handled
|
|
1268
|
+
(an expired current door, a warning given) doesn't."""
|
|
1269
|
+
now = self._now()
|
|
1270
|
+
times: list[int] = []
|
|
1271
|
+
for door in self._store.doors():
|
|
1272
|
+
expires_at = int(door["expires_at"])
|
|
1273
|
+
if door["state"] == "closing":
|
|
1274
|
+
due, _ = self._close_retry.get(str(door["door_id"]), (now, 0))
|
|
1275
|
+
times.append(min(due, expires_at))
|
|
1276
|
+
else:
|
|
1277
|
+
times.append(expires_at + ac.DOOR_GRACE_SECS)
|
|
1278
|
+
if door["state"] == "current":
|
|
1279
|
+
times += [t for t in (expires_at - DOOR_WARNING_SECS, expires_at) if t > now]
|
|
1280
|
+
for row in self._store.listings():
|
|
1281
|
+
if row["state"] == "withdrawing":
|
|
1282
|
+
times.append(row["retry_at"] or now + RETRY_BASE_SECS)
|
|
1283
|
+
if row["state"] not in LIVE:
|
|
1284
|
+
continue
|
|
1285
|
+
expires_at = int(row["token_expires_at"])
|
|
1286
|
+
times.append(expires_at)
|
|
1287
|
+
if not row["token_warned"]:
|
|
1288
|
+
times.append(expires_at - TOKEN_WARNING_SECS)
|
|
1289
|
+
# Only when a round would run: a publish, or a renewal scheduled.
|
|
1290
|
+
renew_at = row["next_renewal_at"]
|
|
1291
|
+
if row["state"] == "publishing" or renew_at is not None:
|
|
1292
|
+
times.append(max(int(row["retry_at"] or 0), int(renew_at or 0)))
|
|
1293
|
+
return float(min(times)) if times else None
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _url(row: dict[str, Any]) -> str:
|
|
1297
|
+
"""The listing's address at its directory (``Listing.url``)."""
|
|
1298
|
+
return f"https://{row['directory']}/a/{row['name']}.json"
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def _token_error(e: ac.AgentConnectError) -> ApiError:
|
|
1302
|
+
if e.kind == "unknown_key":
|
|
1303
|
+
msg = "the token isn't signed by a key pinned for this directory"
|
|
1304
|
+
return ApiError(403, "unknown_key", msg)
|
|
1305
|
+
if e.kind in ("malformed", "unsupported_version"):
|
|
1306
|
+
return ApiError(400, "bad_token", f"not a listing token: {e}")
|
|
1307
|
+
return ApiError(403, "token_invalid", f"the listing token doesn't verify: {e}")
|