@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.83.1
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.
- package/CHANGELOG.md +200 -3
- package/README.md +11 -6
- package/SECURITY.md +8 -5
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +25 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +274 -17
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +24 -151
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +17 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +24 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +23 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +28 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +30 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +29 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +27 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +21 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +88 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +133 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +44 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +35 -159
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +43 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +21 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +39 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +92 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +180 -162
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +274 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +465 -55
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +30 -17
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +53 -35
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +34 -21
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +96 -15
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A person's identity at every door of a service published through `tailscale serve` (FAA-324). Standard library
|
|
3
|
+
only; the Node twin is fabric-tailnet-identity.mjs, and both answer test/fixtures/tailnet-identity/vectors.json alike.
|
|
4
|
+
|
|
5
|
+
`tailscale serve` proxies HTTPS on the node's tailnet name to the service's loopback listener and sets the caller's
|
|
6
|
+
`Tailscale-User-Login` (and `Tailscale-User-Name`, read here for display only). What Tailscale documents
|
|
7
|
+
(https://tailscale.com/kb/1312/serve, "Identity headers", read 2026-11-12) and its source shows
|
|
8
|
+
(tailscale/tailscale `ipn/ipnlocal/serve.go`, `addTailscaleIdentityHeaders` and `delHeaderVariants`):
|
|
9
|
+
|
|
10
|
+
- Serve removes any incoming `Tailscale-User-Login`, `Tailscale-User-Name`, `Tailscale-User-Profile-Pic`,
|
|
11
|
+
`Tailscale-Funnel-Request` and `Tailscale-Headers-Info` (also their `_` spellings) before it sets its own, so a
|
|
12
|
+
tailnet client cannot forge them through serve.
|
|
13
|
+
- It sets no identity header for a tagged node, for Funnel traffic (it sets `Tailscale-Funnel-Request: ?1` instead)
|
|
14
|
+
or for traffic it cannot attribute; it always sets `X-Forwarded-For` to the source address.
|
|
15
|
+
- Its documentation advises the backend to listen on localhost only: a backend reachable directly lets any caller
|
|
16
|
+
supply its own values.
|
|
17
|
+
|
|
18
|
+
So the header is believed only when the service listens on a loopback address and the request's peer is a loopback
|
|
19
|
+
address (it came through the local serve proxy). A service bound to another address, or a request from another
|
|
20
|
+
peer, is refused: there the header comes from the network and is forgeable. What this does not stop: another
|
|
21
|
+
process of this machine can send the header to the loopback port. The kit's `LoopbackHTTPServer` refuses another OS
|
|
22
|
+
account's peer (DEC-0154, FAA-227); a process of the service's own account is trusted as the service is. Where that
|
|
23
|
+
matters, give serve a listener of its own (`fabric_tailnet`, FAA-297).
|
|
24
|
+
|
|
25
|
+
No request is let through by default. A request without a login (a tagged node's, a Funnel request's, a direct
|
|
26
|
+
call's), with an empty, repeated or malformed one, or from a login no rule opens the door for, is refused with a
|
|
27
|
+
stable code and a sentence, and every refusal is HTTP 403. What a login may open comes from a groups file the
|
|
28
|
+
organization writes (`tailnet-groups/1`, its JSON Schema `tailnet-groups.schema.json` beside this file):
|
|
29
|
+
|
|
30
|
+
{"schema": "tailnet-groups/1",
|
|
31
|
+
"groups": {"ops": ["ana@example.com"]},
|
|
32
|
+
"rules": [{"group": "ops", "door": "dashboard", "capabilities": "*"}]}
|
|
33
|
+
|
|
34
|
+
The agents base's `door-groups/1` file is read as well, for one service (`service=`, the service's id): its
|
|
35
|
+
groups name `people` and, per service, the doors and tools they open (`*` every door, `dashboard`, `mcp` every MCP
|
|
36
|
+
tool, or a tool name, opened at the MCP door and at the service's own door); a retired group opens nothing, and
|
|
37
|
+
without a service every door stays closed.
|
|
38
|
+
|
|
39
|
+
Logins are compared as ASCII email-style names lowercased whole (local part and domain alike), after trimming the
|
|
40
|
+
spaces and tabs HTTP allows around a header value; nothing else is changed (no dot or `+tag` folding, no IDN
|
|
41
|
+
mapping), and a login that is not plain ASCII (serve RFC 2047-encodes a non-ASCII one) is refused as malformed.
|
|
42
|
+
`TailnetIdentity` reads the file again when its inode, size, mtime or ctime change (and, while its mtime is under two
|
|
43
|
+
seconds old, when its bytes do: a same-size rewrite within one timestamp tick), without a restart, and while it
|
|
44
|
+
is unreadable or invalid every door refuses (`groups-unreadable`, `groups-invalid`); the reason is logged once per
|
|
45
|
+
change, naming the field, never the file's content."""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import errno
|
|
50
|
+
import hashlib
|
|
51
|
+
import ipaddress
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import re
|
|
55
|
+
import stat
|
|
56
|
+
import sys
|
|
57
|
+
import threading
|
|
58
|
+
import time
|
|
59
|
+
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Union
|
|
60
|
+
|
|
61
|
+
GROUPS_SCHEMA = "tailnet-groups/1"
|
|
62
|
+
DOOR_GROUPS = "door-groups/1" # the agents base's format (thread d23eeb7a), read for one service
|
|
63
|
+
DOORS = ("dashboard", "mcp", "service")
|
|
64
|
+
LOGIN_HEADER = "tailscale-user-login"
|
|
65
|
+
NAME_HEADER = "tailscale-user-name"
|
|
66
|
+
FUNNEL_HEADER = "tailscale-funnel-request"
|
|
67
|
+
#: The largest groups file read; a larger one is invalid.
|
|
68
|
+
MAX_GROUPS_BYTES = 1048576
|
|
69
|
+
# A file whose stat is unchanged but whose mtime is this close to now may have been rewritten within one timestamp tick
|
|
70
|
+
# (same size, coarse clock): its content is read and compared (git's racy-clean rule).
|
|
71
|
+
RACY_NS = 2_000_000_000
|
|
72
|
+
|
|
73
|
+
#: The patterns as the JSON Schema writes them (ECMAScript); compiled below to end at the end of the string.
|
|
74
|
+
LOGIN_PATTERN = "^[A-Za-z0-9._%+-]{1,128}@[A-Za-z0-9.-]{1,125}$"
|
|
75
|
+
NAME_PATTERN = "^[a-z][a-z0-9._:/-]{0,127}$"
|
|
76
|
+
_LOGIN = re.compile(LOGIN_PATTERN[:-1] + r"\Z", re.ASCII)
|
|
77
|
+
_NAME = re.compile(NAME_PATTERN[:-1] + r"\Z", re.ASCII)
|
|
78
|
+
_TAILNET = (ipaddress.ip_network("100.64.0.0/10"), ipaddress.ip_network("fd7a:115c:a1e0::/48"))
|
|
79
|
+
_ENCODED_WORD = re.compile(r"=\?utf-8\?q\?([^?]*)\?=\Z", re.ASCII | re.IGNORECASE)
|
|
80
|
+
|
|
81
|
+
#: code -> HTTP status: every refusal is 403.
|
|
82
|
+
REFUSALS: Dict[str, int] = {code: 403 for code in (
|
|
83
|
+
"bind-not-loopback", "not-loopback", "groups-unreadable", "groups-invalid", "funnel-request", "no-login",
|
|
84
|
+
"tagged-node", "login-ambiguous", "login-malformed", "unknown-login", "not-allowed")}
|
|
85
|
+
|
|
86
|
+
#: code -> the sentence a person reads; the twins carry the same text.
|
|
87
|
+
REASONS: Dict[str, str] = {
|
|
88
|
+
"ok": "Allowed by a rule of a group this login belongs to.",
|
|
89
|
+
"bind-not-loopback": "The service does not listen on a loopback address only, so a Tailscale identity header "
|
|
90
|
+
"could come from the network and is not believed.",
|
|
91
|
+
"not-loopback": "The request did not come from the local tailscale serve proxy (its peer is not a loopback "
|
|
92
|
+
"address), so its Tailscale identity header is not believed.",
|
|
93
|
+
"groups-unreadable": "The tailnet groups file cannot be read, so every door stays closed until it can.",
|
|
94
|
+
"groups-invalid": "The tailnet groups file is not a valid tailnet-groups/1 or door-groups/1 document, so every door stays closed "
|
|
95
|
+
"until it is fixed.",
|
|
96
|
+
"funnel-request": "The request came through Tailscale Funnel from the public internet, which carries no tailnet "
|
|
97
|
+
"identity.",
|
|
98
|
+
"no-login": "The request carries no Tailscale-User-Login header, so the caller is not known.",
|
|
99
|
+
"tagged-node": "The request came through tailscale serve from a tailnet node with no user (a tagged node), which "
|
|
100
|
+
"carries no login.",
|
|
101
|
+
"login-ambiguous": "The request carries more than one Tailscale-User-Login header.",
|
|
102
|
+
"login-malformed": "The Tailscale-User-Login header is not a login name.",
|
|
103
|
+
"unknown-login": "This login is in no group of the tailnet groups file.",
|
|
104
|
+
"not-allowed": "No group of this login has a rule that opens this door for this capability.",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
Headers = Union[Mapping[str, Any], Iterable[Tuple[str, Any]], Any]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class GroupsError(ValueError):
|
|
111
|
+
"""A groups file that cannot be used. `code` is `groups-invalid` or `groups-unreadable`; `where` names the field
|
|
112
|
+
(or the reason the file could not be read), never a value from the file."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, code: str, where: str = ""):
|
|
115
|
+
self.code = code
|
|
116
|
+
self.where = where
|
|
117
|
+
super().__init__("%s%s" % (code, " at " + where if where else ""))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _invalid(where: str) -> GroupsError:
|
|
121
|
+
return GroupsError("groups-invalid", where)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_groups(doc: Any, service: Optional[str] = None) -> Dict[str, Any]:
|
|
125
|
+
"""Check a parsed `tailnet-groups/1` or `door-groups/1` document; answer it normalized (logins lowercased and
|
|
126
|
+
sorted, each group's members once, the rules) or raise GroupsError `groups-invalid` naming the field. A
|
|
127
|
+
`door-groups/1` file is read for one `service` (its id): without one every door stays closed."""
|
|
128
|
+
if not isinstance(doc, dict):
|
|
129
|
+
raise _invalid("document")
|
|
130
|
+
if "format" in doc and "schema" not in doc:
|
|
131
|
+
if doc["format"] != DOOR_GROUPS:
|
|
132
|
+
raise _invalid("format")
|
|
133
|
+
return _load_door_groups(doc, service)
|
|
134
|
+
if doc.get("schema") != GROUPS_SCHEMA:
|
|
135
|
+
raise _invalid("schema")
|
|
136
|
+
if set(doc) - {"schema", "about", "groups", "rules"}:
|
|
137
|
+
raise _invalid("document")
|
|
138
|
+
if "about" in doc and (not isinstance(doc["about"], str) or len(doc["about"]) > 1000):
|
|
139
|
+
raise _invalid("about")
|
|
140
|
+
groups = doc.get("groups")
|
|
141
|
+
if not isinstance(groups, dict) or not all(isinstance(n, str) and _NAME.match(n) for n in groups):
|
|
142
|
+
raise _invalid("groups")
|
|
143
|
+
out: Dict[str, List[str]] = {}
|
|
144
|
+
for name in sorted(groups):
|
|
145
|
+
members = groups[name]
|
|
146
|
+
if not isinstance(members, list) or not all(isinstance(m, str) and _LOGIN.match(m) for m in members):
|
|
147
|
+
raise _invalid("groups.%s" % name)
|
|
148
|
+
out[name] = sorted({m.lower() for m in members})
|
|
149
|
+
rules = doc.get("rules")
|
|
150
|
+
if not isinstance(rules, list):
|
|
151
|
+
raise _invalid("rules")
|
|
152
|
+
norm: List[Dict[str, Any]] = []
|
|
153
|
+
for i, rule in enumerate(rules):
|
|
154
|
+
where = "rules[%d]" % i
|
|
155
|
+
if not isinstance(rule, dict) or set(rule) != {"group", "door", "capabilities"}:
|
|
156
|
+
raise _invalid(where)
|
|
157
|
+
if not isinstance(rule["group"], str) or rule["group"] not in out:
|
|
158
|
+
raise _invalid(where + ".group")
|
|
159
|
+
if rule["door"] not in DOORS:
|
|
160
|
+
raise _invalid(where + ".door")
|
|
161
|
+
caps = rule["capabilities"]
|
|
162
|
+
if caps != "*" and (not isinstance(caps, list) or not caps
|
|
163
|
+
or not all(isinstance(c, str) and _NAME.match(c) for c in caps)):
|
|
164
|
+
raise _invalid(where + ".capabilities")
|
|
165
|
+
norm.append({"group": rule["group"], "door": rule["door"],
|
|
166
|
+
"capabilities": "*" if caps == "*" else sorted(set(caps))})
|
|
167
|
+
return {"schema": GROUPS_SCHEMA, "groups": out, "rules": norm}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _values(headers: Headers, name: str) -> List[str]:
|
|
171
|
+
"""Every value of one header, case-insensitively, from a parsed message (`get_all`), a mapping whose values may
|
|
172
|
+
be lists, or pairs."""
|
|
173
|
+
if hasattr(headers, "get_all") and hasattr(headers, "items"):
|
|
174
|
+
found: List[Any] = list(headers.get_all(name) or [])
|
|
175
|
+
else:
|
|
176
|
+
pairs = headers.items() if isinstance(headers, Mapping) else headers
|
|
177
|
+
found = []
|
|
178
|
+
for key, value in pairs:
|
|
179
|
+
if isinstance(key, str) and key.lower() == name:
|
|
180
|
+
found.extend(value if isinstance(value, (list, tuple)) else [value])
|
|
181
|
+
return [v if isinstance(v, str) else "" for v in found]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _address(text: Any) -> Optional[Any]:
|
|
185
|
+
if not isinstance(text, str):
|
|
186
|
+
return None
|
|
187
|
+
try:
|
|
188
|
+
ip = ipaddress.ip_address(text.split("%", 1)[0])
|
|
189
|
+
except ValueError:
|
|
190
|
+
return None
|
|
191
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
192
|
+
return ip.ipv4_mapped
|
|
193
|
+
return ip
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_loopback(text: Any) -> bool:
|
|
197
|
+
ip = _address(text)
|
|
198
|
+
return ip is not None and ip.is_loopback
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _from_tailnet(values: List[str]) -> bool:
|
|
202
|
+
"""One X-Forwarded-For value that is a Tailscale address (100.64.0.0/10, fd7a:115c:a1e0::/48): serve set it."""
|
|
203
|
+
if len(values) != 1:
|
|
204
|
+
return False
|
|
205
|
+
ip = _address(values[0].strip(" \t"))
|
|
206
|
+
return ip is not None and any(ip.version == net.version and ip in net for net in _TAILNET)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _display_name(values: List[str]) -> Optional[str]:
|
|
210
|
+
"""`Tailscale-User-Name` for display: ASCII as sent, or serve's RFC 2047 `=?utf-8?q?...?=` words decoded; None
|
|
211
|
+
when absent, repeated, over 256 characters or holding a control character."""
|
|
212
|
+
if len(values) != 1:
|
|
213
|
+
return None
|
|
214
|
+
text = values[0].strip(" \t")
|
|
215
|
+
if any(ord(c) > 126 for c in text):
|
|
216
|
+
return None
|
|
217
|
+
if text.startswith("=?"):
|
|
218
|
+
raw = b""
|
|
219
|
+
for word in text.split(" "):
|
|
220
|
+
match = _ENCODED_WORD.match(word)
|
|
221
|
+
if match is None:
|
|
222
|
+
return None
|
|
223
|
+
encoded, i = match.group(1), 0
|
|
224
|
+
while i < len(encoded):
|
|
225
|
+
if encoded[i] == "=":
|
|
226
|
+
pair = encoded[i + 1:i + 3]
|
|
227
|
+
if len(pair) != 2 or not all(c in "0123456789abcdefABCDEF" for c in pair):
|
|
228
|
+
return None
|
|
229
|
+
raw += bytes([int(pair, 16)])
|
|
230
|
+
i += 3
|
|
231
|
+
else:
|
|
232
|
+
raw += b" " if encoded[i] == "_" else encoded[i].encode("ascii")
|
|
233
|
+
i += 1
|
|
234
|
+
try:
|
|
235
|
+
text = raw.decode("utf-8")
|
|
236
|
+
except UnicodeDecodeError:
|
|
237
|
+
return None
|
|
238
|
+
if not text or len(text) > 256 or any(ord(c) < 32 or ord(c) == 127 for c in text):
|
|
239
|
+
return None
|
|
240
|
+
return text
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _load_door_groups(doc: Dict[str, Any], service: Optional[str]) -> Dict[str, Any]:
|
|
244
|
+
"""`door-groups/1` (the agents base): {format, version, generated_at?, groups: {name: {people, doors: {service:
|
|
245
|
+
[capability]}}}, retired?: {name: {at, why}}}. For `service`: `*` opens every door, `dashboard` the dashboard,
|
|
246
|
+
`mcp` every MCP tool, any other name that capability at the MCP door and at the service's own door; a group with
|
|
247
|
+
no entry for the service, and a retired group, open nothing."""
|
|
248
|
+
if not isinstance(service, str) or not service:
|
|
249
|
+
raise _invalid("service")
|
|
250
|
+
if set(doc) - {"format", "version", "generated_at", "groups", "retired"}:
|
|
251
|
+
raise _invalid("document")
|
|
252
|
+
version = doc.get("version")
|
|
253
|
+
if isinstance(version, float) and version.is_integer(): # JSON's 3.0 is JavaScript's 3, as the Node twin reads it
|
|
254
|
+
version = int(version)
|
|
255
|
+
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
|
256
|
+
raise _invalid("version")
|
|
257
|
+
if doc.get("generated_at") is not None and (not isinstance(doc["generated_at"], str) or len(doc["generated_at"]) > 64):
|
|
258
|
+
raise _invalid("generated_at")
|
|
259
|
+
groups = doc.get("groups")
|
|
260
|
+
if not isinstance(groups, dict) or not all(isinstance(n, str) and _NAME.match(n) for n in groups):
|
|
261
|
+
raise _invalid("groups")
|
|
262
|
+
retired = doc.get("retired", {})
|
|
263
|
+
if not isinstance(retired, dict) or not all(isinstance(n, str) and _NAME.match(n) for n in retired):
|
|
264
|
+
raise _invalid("retired")
|
|
265
|
+
for name in sorted(retired):
|
|
266
|
+
entry = retired[name]
|
|
267
|
+
if name in groups:
|
|
268
|
+
raise _invalid("retired.%s" % name)
|
|
269
|
+
if not isinstance(entry, dict) or set(entry) - {"at", "why"} or not all(
|
|
270
|
+
v is None or (isinstance(v, str) and len(v) <= 1000) for v in entry.values()):
|
|
271
|
+
raise _invalid("retired.%s" % name)
|
|
272
|
+
people: Dict[str, List[str]] = {}
|
|
273
|
+
rules: List[Dict[str, Any]] = []
|
|
274
|
+
for name in sorted(groups):
|
|
275
|
+
group = groups[name]
|
|
276
|
+
if not isinstance(group, dict) or set(group) != {"people", "doors"}:
|
|
277
|
+
raise _invalid("groups.%s" % name)
|
|
278
|
+
members = group["people"]
|
|
279
|
+
if not isinstance(members, list) or not all(isinstance(m, str) and _LOGIN.match(m) for m in members):
|
|
280
|
+
raise _invalid("groups.%s.people" % name)
|
|
281
|
+
doors = group["doors"]
|
|
282
|
+
if not isinstance(doors, dict) or not all(isinstance(k, str) and k for k in doors):
|
|
283
|
+
raise _invalid("groups.%s.doors" % name)
|
|
284
|
+
for key in sorted(doors):
|
|
285
|
+
caps = doors[key]
|
|
286
|
+
if not isinstance(caps, list) or not caps or not all(
|
|
287
|
+
isinstance(c, str) and (c == "*" or _NAME.match(c)) for c in caps):
|
|
288
|
+
raise _invalid("groups.%s.doors.%s" % (name, key))
|
|
289
|
+
people[name] = sorted({m.lower() for m in members})
|
|
290
|
+
caps = set(doors.get(service) or [])
|
|
291
|
+
tools = sorted(caps - {"*", "dashboard", "mcp"})
|
|
292
|
+
opened = {"dashboard": "*" if caps & {"*", "dashboard"} else None,
|
|
293
|
+
"mcp": "*" if caps & {"*", "mcp"} else (tools or None),
|
|
294
|
+
"service": "*" if "*" in caps else (tools or None)}
|
|
295
|
+
rules.extend({"group": name, "door": door, "capabilities": opened[door]} for door in DOORS if opened[door])
|
|
296
|
+
return {"schema": DOOR_GROUPS, "groups": people, "rules": rules}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _decision(code: str, door: str, capability: str, login: Optional[str] = None, name: Optional[str] = None,
|
|
300
|
+
groups: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
301
|
+
return {"allowed": code == "ok", "status": 200 if code == "ok" else REFUSALS[code], "code": code,
|
|
302
|
+
"reason": REASONS[code], "login": login, "name": name, "groups": list(groups or []), "door": door,
|
|
303
|
+
"capability": capability}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def decide(*, groups: Union[Mapping[str, Any], GroupsError], bound: Any, peer: Any, headers: Headers, door: str,
|
|
307
|
+
capability: str) -> Dict[str, Any]:
|
|
308
|
+
"""Decide one request. `groups` is what load_groups answered, or the GroupsError that keeps every door closed;
|
|
309
|
+
`bound` the address the service listens on; `peer` the connection's remote address; `door` one of DOORS;
|
|
310
|
+
`capability` the name the service asks for (a page, a tool, an action). Answers {allowed, status, code, reason,
|
|
311
|
+
login, name, groups, door, capability}: 200 `ok`, else 403 with the refusal's code. Never raises for a request;
|
|
312
|
+
a door or capability outside the rules' grammar is the service's mistake and raises ValueError."""
|
|
313
|
+
if door not in DOORS:
|
|
314
|
+
raise ValueError("door is one of %s" % ", ".join(DOORS))
|
|
315
|
+
if not isinstance(capability, str) or not _NAME.match(capability):
|
|
316
|
+
raise ValueError("capability is a name matching %s" % NAME_PATTERN)
|
|
317
|
+
if not _is_loopback(bound):
|
|
318
|
+
return _decision("bind-not-loopback", door, capability)
|
|
319
|
+
if not _is_loopback(peer):
|
|
320
|
+
return _decision("not-loopback", door, capability)
|
|
321
|
+
if isinstance(groups, GroupsError):
|
|
322
|
+
return _decision(groups.code if groups.code in REFUSALS else "groups-invalid", door, capability)
|
|
323
|
+
if _values(headers, FUNNEL_HEADER):
|
|
324
|
+
return _decision("funnel-request", door, capability)
|
|
325
|
+
logins = [v.strip(" \t") for v in _values(headers, LOGIN_HEADER)]
|
|
326
|
+
logins = [v for v in logins if v]
|
|
327
|
+
if not logins:
|
|
328
|
+
code = "tagged-node" if _from_tailnet(_values(headers, "x-forwarded-for")) else "no-login"
|
|
329
|
+
return _decision(code, door, capability)
|
|
330
|
+
if len(logins) > 1:
|
|
331
|
+
return _decision("login-ambiguous", door, capability)
|
|
332
|
+
if not _LOGIN.match(logins[0]):
|
|
333
|
+
return _decision("login-malformed", door, capability)
|
|
334
|
+
login = logins[0].lower()
|
|
335
|
+
name = _display_name(_values(headers, NAME_HEADER))
|
|
336
|
+
member_of = sorted(g for g, members in groups["groups"].items() if login in members)
|
|
337
|
+
if not member_of:
|
|
338
|
+
return _decision("unknown-login", door, capability, login, name)
|
|
339
|
+
for rule in groups["rules"]:
|
|
340
|
+
if rule["group"] in member_of and rule["door"] == door and (
|
|
341
|
+
rule["capabilities"] == "*" or capability in rule["capabilities"]):
|
|
342
|
+
return _decision("ok", door, capability, login, name, member_of)
|
|
343
|
+
return _decision("not-allowed", door, capability, login, name, member_of)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def refusal_body(decision: Mapping[str, Any]) -> Dict[str, Any]:
|
|
347
|
+
"""The JSON body of a refusal: {"error": {"code", "message"}}; never a header value or a login."""
|
|
348
|
+
if decision.get("allowed") or decision.get("code") not in REFUSALS:
|
|
349
|
+
raise ValueError("refusal_body takes a refused decision")
|
|
350
|
+
return {"error": {"code": decision["code"], "message": REASONS[decision["code"]]}}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def send_refusal(handler: Any, decision: Mapping[str, Any]) -> None:
|
|
354
|
+
"""Answer a refused decision on an http.server handler: 403, JSON, no-store."""
|
|
355
|
+
body = json.dumps(refusal_body(decision)).encode("utf-8")
|
|
356
|
+
handler.send_response(REFUSALS[decision["code"]])
|
|
357
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
358
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
359
|
+
handler.send_header("Cache-Control", "no-store")
|
|
360
|
+
handler.send_header("X-Content-Type-Options", "nosniff")
|
|
361
|
+
handler.end_headers()
|
|
362
|
+
handler.wfile.write(body)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _errno_name(exc: OSError) -> str:
|
|
366
|
+
"""The error's symbolic name (ENOENT, EACCES), as Node names it; never its message, which may hold the path."""
|
|
367
|
+
return errno.errorcode.get(exc.errno or 0, "unknown")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _stderr(line: str) -> None:
|
|
371
|
+
sys.stderr.write(line + "\n")
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class TailnetIdentity:
|
|
375
|
+
"""The groups file at `path`, read again when it changes, and the decision at a door. `recheck_seconds` 0 looks
|
|
376
|
+
at the file on every request (one stat); a positive value at most that often. `log` takes one line per change of
|
|
377
|
+
the file's state (default stderr); `clock` gives monotonic seconds (a test replaces it)."""
|
|
378
|
+
|
|
379
|
+
def __init__(self, path: Union[str, "os.PathLike[str]"], *, recheck_seconds: float = 0.0,
|
|
380
|
+
log: Optional[Callable[[str], None]] = None, clock: Callable[[], float] = time.monotonic,
|
|
381
|
+
service: Optional[str] = None) -> None:
|
|
382
|
+
if not isinstance(recheck_seconds, (int, float)) or recheck_seconds < 0:
|
|
383
|
+
raise ValueError("recheck_seconds is a number of seconds, 0 or more")
|
|
384
|
+
self.path = os.fspath(path)
|
|
385
|
+
self.service = service # the service's id: the entry a door-groups/1 file is read for
|
|
386
|
+
self.recheck_seconds = float(recheck_seconds)
|
|
387
|
+
self._log = log or _stderr
|
|
388
|
+
self._clock = clock
|
|
389
|
+
self._lock = threading.Lock()
|
|
390
|
+
self._signature: Any = object() # nothing read yet
|
|
391
|
+
self._digest: Optional[str] = None # sha256 of the bytes last read
|
|
392
|
+
self._groups: Union[Dict[str, Any], GroupsError] = GroupsError("groups-unreadable", "not read")
|
|
393
|
+
self._checked: Optional[float] = None
|
|
394
|
+
self._logged: Any = None
|
|
395
|
+
|
|
396
|
+
def _stat(self) -> Tuple[Any, ...]:
|
|
397
|
+
try:
|
|
398
|
+
st = os.stat(self.path)
|
|
399
|
+
except OSError as exc:
|
|
400
|
+
return ("error", _errno_name(exc))
|
|
401
|
+
if not stat.S_ISREG(st.st_mode):
|
|
402
|
+
return ("error", "not-a-file")
|
|
403
|
+
return (st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns)
|
|
404
|
+
|
|
405
|
+
def _read(self) -> Union[Dict[str, Any], GroupsError]:
|
|
406
|
+
try:
|
|
407
|
+
with open(self.path, "rb") as handle:
|
|
408
|
+
data = handle.read(MAX_GROUPS_BYTES + 1)
|
|
409
|
+
except OSError as exc:
|
|
410
|
+
self._digest = None
|
|
411
|
+
return GroupsError("groups-unreadable", _errno_name(exc))
|
|
412
|
+
self._digest = hashlib.sha256(data).hexdigest()
|
|
413
|
+
if len(data) > MAX_GROUPS_BYTES:
|
|
414
|
+
return _invalid("size")
|
|
415
|
+
try:
|
|
416
|
+
text = data.decode("utf-8")
|
|
417
|
+
except UnicodeDecodeError:
|
|
418
|
+
return _invalid("encoding")
|
|
419
|
+
|
|
420
|
+
def no_constant(_name: str) -> Any:
|
|
421
|
+
raise ValueError("not JSON")
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
doc = json.loads(text, parse_constant=no_constant)
|
|
425
|
+
except ValueError:
|
|
426
|
+
return _invalid("json")
|
|
427
|
+
try:
|
|
428
|
+
return load_groups(doc, self.service)
|
|
429
|
+
except GroupsError as exc:
|
|
430
|
+
return exc
|
|
431
|
+
|
|
432
|
+
def current(self) -> Union[Dict[str, Any], GroupsError]:
|
|
433
|
+
"""The groups in force: the normalized file, or the GroupsError that keeps every door closed."""
|
|
434
|
+
with self._lock:
|
|
435
|
+
now = self._clock()
|
|
436
|
+
if self._checked is not None and self.recheck_seconds and now - self._checked < self.recheck_seconds:
|
|
437
|
+
return self._groups
|
|
438
|
+
self._checked = now
|
|
439
|
+
signature = self._stat()
|
|
440
|
+
if signature == self._signature and signature[0] != "error" and self._rewritten_in_place(signature):
|
|
441
|
+
self._signature = object()
|
|
442
|
+
if signature != self._signature:
|
|
443
|
+
self._signature = signature
|
|
444
|
+
self._groups = (GroupsError("groups-unreadable", signature[1])
|
|
445
|
+
if signature[0] == "error" else self._read())
|
|
446
|
+
self._report(signature)
|
|
447
|
+
return self._groups
|
|
448
|
+
|
|
449
|
+
def _rewritten_in_place(self, signature: Tuple[Any, ...]) -> bool:
|
|
450
|
+
"""Whether a file whose stat did not change holds other bytes: asked only while its mtime is within RACY_NS of
|
|
451
|
+
now, since a rewrite of the same size within one timestamp tick leaves every stat field as it was."""
|
|
452
|
+
if abs(time.time_ns() - signature[3]) >= RACY_NS:
|
|
453
|
+
return False
|
|
454
|
+
try:
|
|
455
|
+
with open(self.path, "rb") as handle:
|
|
456
|
+
data = handle.read(MAX_GROUPS_BYTES + 1)
|
|
457
|
+
except OSError:
|
|
458
|
+
return True # read it again the usual way, which names the error
|
|
459
|
+
return hashlib.sha256(data).hexdigest() != self._digest
|
|
460
|
+
|
|
461
|
+
def _report(self, signature: Any) -> None:
|
|
462
|
+
state = self._groups
|
|
463
|
+
if isinstance(state, GroupsError):
|
|
464
|
+
key = (signature, state.code, state.where)
|
|
465
|
+
line = ("fabric: tailnet groups file %s: %s (%s); every door refuses until it is fixed"
|
|
466
|
+
% (self.path, state.code, state.where))
|
|
467
|
+
else:
|
|
468
|
+
key = (signature, "ok")
|
|
469
|
+
line = ("fabric: tailnet groups file %s loaded: %d groups, %d rules"
|
|
470
|
+
% (self.path, len(state["groups"]), len(state["rules"])))
|
|
471
|
+
if key != self._logged:
|
|
472
|
+
self._logged = key
|
|
473
|
+
try:
|
|
474
|
+
self._log(line)
|
|
475
|
+
except Exception: # a log that fails never opens a door
|
|
476
|
+
pass
|
|
477
|
+
|
|
478
|
+
def decide(self, *, bound: Any, peer: Any, headers: Headers, door: str, capability: str) -> Dict[str, Any]:
|
|
479
|
+
"""decide() with the groups in force."""
|
|
480
|
+
return decide(groups=self.current(), bound=bound, peer=peer, headers=headers, door=door,
|
|
481
|
+
capability=capability)
|
|
482
|
+
|
|
483
|
+
def decide_request(self, handler: Any, *, door: str, capability: str, bound: Any = None) -> Dict[str, Any]:
|
|
484
|
+
"""decide() for an http.server handler: the address the server listens on (`bound` overrides it), the
|
|
485
|
+
connection's peer and the request's headers. A bound address nobody can tell is refused."""
|
|
486
|
+
if bound is None:
|
|
487
|
+
address = getattr(getattr(handler, "server", None), "server_address", None)
|
|
488
|
+
bound = address[0] if isinstance(address, tuple) and address else None
|
|
489
|
+
client = getattr(handler, "client_address", None)
|
|
490
|
+
peer = client[0] if isinstance(client, tuple) and client else None
|
|
491
|
+
return self.decide(bound=bound, peer=peer, headers=getattr(handler, "headers", None) or [], door=door,
|
|
492
|
+
capability=capability)
|