@vibelord/lanes 1.0.3 → 1.1.0
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/README.md +55 -15
- package/bin/lanes-install.mjs +8 -2
- package/lib/plan.mjs +34 -1
- package/package.json +2 -2
- package/payload/lanes/README.md +91 -33
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +994 -49
- package/payload/lanes/client/lanes-launch +160 -14
- package/payload/lanes/client/ssh.stanza.in +11 -2
- package/payload/lanes/docs/ARCHITECTURE.md +88 -3
- package/payload/lanes/docs/INSTALL.md +297 -43
- package/payload/lanes/docs/RUNBOOK.md +458 -31
- package/payload/lanes/docs/USERGUIDE.md +1612 -0
- package/payload/lanes/install/enable-phone.sh +8 -20
- package/payload/lanes/install/enable-web.sh +2 -2
- package/payload/lanes/install/host-install.sh +6 -0
- package/payload/lanes/install.sh +888 -52
- package/payload/lanes/libexec/enrol.py +695 -0
- package/payload/lanes/libexec/enrold.py +1264 -0
- package/payload/lanes/libexec/keys.py +644 -0
- package/payload/lanes/libexec/lanes_lib.py +14 -6
- package/payload/lanes/libexec/members.py +297 -0
- package/payload/lanes/libexec/notify.py +146 -21
- package/payload/lanes/libexec/qr.py +217 -0
- package/payload/lanes/libexec/site_edit.py +237 -0
- package/payload/lanes/libexec/web_tokens.py +315 -0
- package/payload/lanes/site/README.md +13 -1
- package/payload/lanes/site/beyondwork/site.conf +3 -0
- package/payload/lanes/site/site.example.conf +5 -0
- package/payload/lanes/sudo/one-time-root.sh +1 -1
- package/payload/lanes/systemd/lanes-enrold.service +22 -0
- package/payload/lanes/systemd/lanes-notify.service +4 -4
- package/payload/lanes/systemd/lanes-web.service +4 -0
- package/payload/lanes/web/Caddyfile +10 -0
- package/payload/lanes/web/README.md +8 -2
- package/payload/lanes/workspaces.example/README.md +6 -1
- package/payload/manifest.json +7 -4
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""lanes-enrold — the tailnet enrolment service (W8 A3, LNS-42).
|
|
3
|
+
|
|
4
|
+
A new machine gets its SSH key into the workspace account without an admin editing authorized_keys: it redeems an
|
|
5
|
+
invite code, or files a request a member approves. `lanes-enrold.service` runs this file on 127.0.0.1:8184
|
|
6
|
+
(`LANES_ENROL_PORT`; `--port`), behind the loopback Caddy of `lanes-mobile.service` (`handle_path /enrol/*`, which
|
|
7
|
+
strips `/enrol`) behind Tailscale Serve (`https://<host>.<tailnet>.ts.net:8443/enrol/...`). Stdlib only.
|
|
8
|
+
|
|
9
|
+
Wire contract v1 (JSON; additive only — a client ignores keys it does not know). Request bodies are at most 8 KiB
|
|
10
|
+
(413 above, 411 without a Content-Length):
|
|
11
|
+
|
|
12
|
+
POST /redeem {code, pubkey, device, client_version}
|
|
13
|
+
200 {device, owner, ssh_ready: true} the key is in the lanes-managed block
|
|
14
|
+
403 {error: "invalid-or-expired"} ONE answer for an unknown, expired, revoked or used code
|
|
15
|
+
and for a key that is not one ssh-ed25519 public key
|
|
16
|
+
400 {error: "invalid-device"|"bad-request"}, 409 {error: "device-taken"|"key-taken"} (only after a
|
|
17
|
+
valid code: nobody learns device names without one), 429 {error: "rate-limited"} + Retry-After
|
|
18
|
+
(5 failures per source in 10 minutes; the source is the client's tailnet address, from the
|
|
19
|
+
X-Forwarded-For Serve sets and Caddy keeps — see source_key; a 409 is not a failure: only a valid
|
|
20
|
+
code reaches it, and its holder retries with another --device), 503 {error: "server-error"|"busy"}
|
|
21
|
+
POST /request {pubkey, device, name?, client_version} needs request mode, a tailnet login and an allowed domain
|
|
22
|
+
202 {id, expires_at} 24 h; the same login+device+key again returns the same one
|
|
23
|
+
403 {error: "requests-disabled"|"no-identity"|"domain-not-allowed"}, 400 {error: "invalid-device"|
|
|
24
|
+
"invalid-pubkey"|"invalid-name"|"bad-request"}, 409 device-taken|key-taken, 429 too-many-requests
|
|
25
|
+
(3 open requests per login)
|
|
26
|
+
GET /request/<id> {id, state: pending|approved|denied|expired, expires_at}; 404 for another login's request
|
|
27
|
+
GET /site {alias, host_name, host_key, version, request_mode, caller, source} (`caller` = the login received,
|
|
28
|
+
`source` = the rate-limit source derived: both are the operator's live checks of the proxy chain)
|
|
29
|
+
GET|POST /pending HTML: the open requests with Approve/Deny forms, for a caller whose login owns a member record
|
|
30
|
+
(and passes the domain gate, in request mode; a key added by hand has no owner until `lanes keys
|
|
31
|
+
owner <device> <login>` names one). A form carries an HMAC token bound to the caller and the
|
|
32
|
+
request (per process: a restart asks for a reload), and a POST is decided only when its
|
|
33
|
+
Sec-Fetch-Site is `same-origin` or `none`: cross-site, same-site and ABSENT are refused (every
|
|
34
|
+
browser that reaches it through Serve sends the header; curl is not a way to decide —
|
|
35
|
+
`lanes enrol approve` is).
|
|
36
|
+
|
|
37
|
+
Identity is what Tailscale Serve injects for a user's device: `Tailscale-User-Login` / `Tailscale-User-Name` (a
|
|
38
|
+
non-ASCII value RFC 2047-encoded). Whether Serve replaces a header a client sends is the operator's live check
|
|
39
|
+
(RUNBOOK "Enrolment service"): `/site` echoes the login it received as `caller`. `request_mode` comes from, in this
|
|
40
|
+
order: LANES_ENROL_REQUEST_MODE in the environment (1 on, 0 invite-only); the pin `lanes enrol mode request|invite`
|
|
41
|
+
writes to STATE/enrol-mode (read on every request; a pin that cannot be read is invite-only); else the latch — on when
|
|
42
|
+
the FIRST request that came through Tailscale Serve (through_serve: its X-Forwarded-For names a tailnet address) carried
|
|
43
|
+
that header set. A loopback probe never decides it (DEF-1725). The operator pins the mode once the header check
|
|
44
|
+
passes. Serve replaces a forged header only on the tailnet path: a process on this host that reaches loopback can
|
|
45
|
+
send one itself (Caddy and this service cannot tell it from Serve), so request mode is safe only where no account but
|
|
46
|
+
the workspace account and root runs in the host's network namespace (DEF-1645, open; RUNBOOK "Trust boundary").
|
|
47
|
+
In request mode a redeemed invite belongs to the trusted caller: the owner's login and email are the caller's (its
|
|
48
|
+
display name too), whoever the invite was minted for, and the invite's own name and email are kept beside it as the
|
|
49
|
+
member's `invited_as` (invite_owner, invited_as). Without request mode a login is not trusted: /request and /pending
|
|
50
|
+
refuse, and a redeemed invite takes its owner from the invite (invite_owner: its email when that is an address, else
|
|
51
|
+
its name when that is itself a login — a display name never is, and then the member has no owner; no `invited_as`).
|
|
52
|
+
LANES_ENROL_DOMAINS (default beyondwork.ai; comma/space separated, `*` = any) gates /request and /pending.
|
|
53
|
+
|
|
54
|
+
Stores under ~/.local/state/lanes (L.STATE), every change under keys.members_lock() — the lock `lanes keys` holds for
|
|
55
|
+
members/ and authorized_keys, so the CLI and this service never interleave:
|
|
56
|
+
invites/<id>.json written by `lanes invite` (enrol.py, W8 A4): {id, code_sha256, minted_at, minted_by {device,
|
|
57
|
+
login, unix_user}, expires_at, name, email, workspaces, redeemed_by, revoked_at};
|
|
58
|
+
code_sha256 = sha256 of canonical_code(code); minted_by.login is null from the CLI, so the
|
|
59
|
+
member's approved_by is too (minter_login). A redemption adds redeemed_by {device, login,
|
|
60
|
+
pubkey_sha256} and redeemed_at; `lanes invite revoke` sets revoked_at. A redeem
|
|
61
|
+
compares sha256(code) against EVERY live invite with hmac.compare_digest (no early exit).
|
|
62
|
+
For an invite-only server `lanes invite` should record an `email` (or a handle as
|
|
63
|
+
`name`): that is where a redeemed member's owner login comes from.
|
|
64
|
+
enrol-requests/<id>.json {id, state, login, name, email, device, pubkey, pubkey_sha256, client_version,
|
|
65
|
+
created_at, expires_at, decided_by, decided_at}; `expired` is derived, never written.
|
|
66
|
+
The opt-in lanes-notify service (notify.py, W8 A5) reads them through load_requests and
|
|
67
|
+
pushes one note per new pending request.
|
|
68
|
+
members/<device>.json members.py's schema, via invite|request, written through keys.py (save_member, then
|
|
69
|
+
write_authorized_keys) — never devices/.
|
|
70
|
+
enrol-mode {version: 1, mode: request|invite, set_at, set_by}, written by `lanes enrol mode` (enrol.py,
|
|
71
|
+
through write_mode; `auto` removes it); read on every request, never written here.
|
|
72
|
+
enrol.log append-only, one line per attempt: `<UTC> <event> <id> <device> <login> fp=<SHA256:…> <result>`
|
|
73
|
+
(`-` for an unknown field). A code is never written anywhere, not even its id half on a miss.
|
|
74
|
+
A redeem or approval re-renders the whole block from the members keys.load_members accepts,
|
|
75
|
+
so a member file it cannot use loses its line (fail closed): each such file is one line
|
|
76
|
+
`<event> <id> <its device> <login> fp=- dropped-member` before the attempt's own, and the
|
|
77
|
+
journal names it as `lanes keys` does (report_dropped).
|
|
78
|
+
"""
|
|
79
|
+
import argparse
|
|
80
|
+
import calendar
|
|
81
|
+
import collections
|
|
82
|
+
import email.errors
|
|
83
|
+
import email.header
|
|
84
|
+
import hashlib
|
|
85
|
+
import hmac
|
|
86
|
+
import html
|
|
87
|
+
import ipaddress
|
|
88
|
+
import itertools
|
|
89
|
+
import json
|
|
90
|
+
import os
|
|
91
|
+
import re
|
|
92
|
+
import secrets
|
|
93
|
+
import shutil
|
|
94
|
+
import socket
|
|
95
|
+
import socketserver
|
|
96
|
+
import sys
|
|
97
|
+
import threading
|
|
98
|
+
import time
|
|
99
|
+
import traceback
|
|
100
|
+
import urllib.parse
|
|
101
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
102
|
+
from pathlib import Path
|
|
103
|
+
|
|
104
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
105
|
+
import lanes_lib as L # noqa: E402
|
|
106
|
+
import keys as K # noqa: E402
|
|
107
|
+
import members as M # noqa: E402
|
|
108
|
+
|
|
109
|
+
WIRE_VERSION = 1
|
|
110
|
+
DEFAULT_PORT = 8184 # web/Caddyfile `handle_path /enrol/*` forwards here
|
|
111
|
+
MAX_BODY = 8 * 1024
|
|
112
|
+
DISCARD_LIMIT = 1024 * 1024 # an oversize body is read away up to this, so the 413 reaches the proxy
|
|
113
|
+
CODE_ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz' # 31 characters: no 0/1/i/l/o; codes are LN-<id4>-<secret12>
|
|
114
|
+
MAX_CODE = 64
|
|
115
|
+
INVITE_TTL = 7 * 86400 # what `lanes invite` mints (A4); enrold reads expires_at, never this
|
|
116
|
+
REQUEST_TTL = 24 * 3600
|
|
117
|
+
REQUEST_ID_LENGTH = 8
|
|
118
|
+
REDEEM_FAILURES = 5
|
|
119
|
+
REDEEM_WINDOW = 600
|
|
120
|
+
MAX_SOURCES = 4096
|
|
121
|
+
OPEN_REQUESTS = 3
|
|
122
|
+
DEFAULT_DOMAINS = ('beyondwork.ai',)
|
|
123
|
+
HOST_KEY = Path('/etc/ssh/ssh_host_ed25519_key.pub')
|
|
124
|
+
TAILNET_TTL = 300
|
|
125
|
+
INVALID = 'invalid-or-expired'
|
|
126
|
+
LOGIN_HEADER = 'Tailscale-User-Login'
|
|
127
|
+
NAME_HEADER = 'Tailscale-User-Name'
|
|
128
|
+
STATES = ('pending', 'approved', 'denied') # stored; `expired` is derived from expires_at
|
|
129
|
+
PINNED_MODES = ('request', 'invite') # STATE/enrol-mode (`lanes enrol mode`); no file = auto
|
|
130
|
+
MODE_VERSION = 1
|
|
131
|
+
ACTIONS = ('approve', 'deny')
|
|
132
|
+
HTML_POLICY = "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
|
|
133
|
+
_HEX64 = re.compile(r'[0-9a-f]{64}')
|
|
134
|
+
_REQUEST_ID = re.compile(r'[a-z0-9]{1,32}')
|
|
135
|
+
_CLIENT_VERSION = re.compile(r'[A-Za-z0-9._+-]{1,32}')
|
|
136
|
+
_STAMP = re.compile(r'\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ', re.ASCII)
|
|
137
|
+
_LOG_UNSAFE = re.compile(r'[^A-Za-z0-9_.@:+=/-]')
|
|
138
|
+
_SITE_LINE = re.compile(r'^(LANES_[A-Z_]+)=([A-Za-z0-9_.:@/-]*)$')
|
|
139
|
+
_ADDRESS = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s]+')
|
|
140
|
+
|
|
141
|
+
Caller = collections.namedtuple('Caller', 'login name present')
|
|
142
|
+
NOBODY = Caller(None, None, False)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------------------------------------------- basics
|
|
146
|
+
|
|
147
|
+
def stamp(t):
|
|
148
|
+
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(t))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_stamp(value):
|
|
152
|
+
"""A UTC `YYYY-MM-DDTHH:MM:SSZ` -> epoch seconds, else None (an unreadable expiry is an expired one)."""
|
|
153
|
+
if not isinstance(value, str) or not _STAMP.fullmatch(value):
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
return calendar.timegm(time.strptime(value, '%Y-%m-%dT%H:%M:%SZ'))
|
|
157
|
+
except ValueError:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def invites_dir():
|
|
162
|
+
return L.STATE / 'invites'
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def requests_dir():
|
|
166
|
+
return L.STATE / 'enrol-requests'
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def log_path():
|
|
170
|
+
return L.STATE / 'enrol.log'
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def mode_path():
|
|
174
|
+
return L.STATE / 'enrol-mode'
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ----------------------------------------------------------------------------------------------- the pinned mode
|
|
178
|
+
# `lanes enrol mode request|invite` (enrol.py) pins request mode in STATE/enrol-mode, so it no longer rests on which
|
|
179
|
+
# request happens to arrive first after each start; `auto` removes the file. The service reads it on every request (a
|
|
180
|
+
# change needs no restart). LANES_ENROL_REQUEST_MODE in the unit's environment still wins over it.
|
|
181
|
+
|
|
182
|
+
def read_mode():
|
|
183
|
+
"""-> (mode, problem): mode 'request' | 'invite' | None (no pin: auto). A pin that cannot be read as
|
|
184
|
+
{version: 1, mode: request|invite, set_at, set_by} is 'invite' with the reason — fail closed, never auto: the
|
|
185
|
+
operator pinned something, and requests stay off until `lanes enrol mode` writes it again."""
|
|
186
|
+
path = mode_path()
|
|
187
|
+
try:
|
|
188
|
+
data = path.read_bytes()
|
|
189
|
+
except FileNotFoundError:
|
|
190
|
+
return None, None
|
|
191
|
+
except OSError as error:
|
|
192
|
+
return 'invite', f'{path} is unreadable ({error.strerror or error})'
|
|
193
|
+
document = _decoded(data)
|
|
194
|
+
if not (isinstance(document, dict) and document.get('version') == MODE_VERSION
|
|
195
|
+
and document.get('mode') in PINNED_MODES):
|
|
196
|
+
return 'invite', f'{path} is not a version {MODE_VERSION} mode pin'
|
|
197
|
+
return document['mode'], None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def write_mode(mode, set_by, now=None):
|
|
201
|
+
"""Pin `mode` ('request' | 'invite'), or remove the pin for 'auto'. -> the document written (None for auto)."""
|
|
202
|
+
if mode == 'auto':
|
|
203
|
+
mode_path().unlink(missing_ok=True)
|
|
204
|
+
return None
|
|
205
|
+
if mode not in PINNED_MODES:
|
|
206
|
+
raise L.LanesError(f'unknown mode {mode}')
|
|
207
|
+
document = {'version': MODE_VERSION, 'mode': mode, 'set_at': stamp(time.time() if now is None else now),
|
|
208
|
+
'set_by': set_by}
|
|
209
|
+
L.STATE.mkdir(parents=True, exist_ok=True)
|
|
210
|
+
L.atomic_write(mode_path(), _json_bytes(document), mode=0o600)
|
|
211
|
+
return document
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _decoded(data):
|
|
215
|
+
try:
|
|
216
|
+
return json.loads(data.decode('utf-8'))
|
|
217
|
+
except (UnicodeDecodeError, ValueError):
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def version():
|
|
222
|
+
try:
|
|
223
|
+
return (L.REPO / 'VERSION').read_text().strip() or 'dev'
|
|
224
|
+
except OSError:
|
|
225
|
+
return 'dev'
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def canonical_code(code):
|
|
229
|
+
"""The form a code is hashed in: blanks removed, lower case (codes are case-insensitive)."""
|
|
230
|
+
return ''.join(code.split()).lower()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def code_sha256(code):
|
|
234
|
+
"""invites/<id>.json `code_sha256`: hex sha256 of canonical_code(code). `lanes invite` (A4) stores this."""
|
|
235
|
+
return hashlib.sha256(canonical_code(code).encode('utf-8')).hexdigest()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def parse_domains(value):
|
|
239
|
+
"""LANES_ENROL_DOMAINS -> a tuple of lower-case domains (`*` = any); unset or blank = DEFAULT_DOMAINS."""
|
|
240
|
+
domains = tuple(part.lower() for part in re.split(r'[,\s]+', value or '') if part)
|
|
241
|
+
return domains or DEFAULT_DOMAINS
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def through_serve(forwarded, peer):
|
|
245
|
+
"""Whether a request came the Tailscale Serve way: its rate-limit source (source_key) is an address that is not
|
|
246
|
+
loopback — Serve sets X-Forwarded-For to the client's tailnet address and Caddy keeps it. A probe of 127.0.0.1:8184,
|
|
247
|
+
or of Caddy from this host, has none: it says nothing about what Serve injects, so it never decides request mode."""
|
|
248
|
+
try:
|
|
249
|
+
return not ipaddress.ip_address(source_key(forwarded, peer)).is_loopback
|
|
250
|
+
except ValueError:
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def source_key(forwarded, peer):
|
|
255
|
+
"""The rate-limit source: the last X-Forwarded-For hop before the loopback proxies, else the socket peer. Tailscale
|
|
256
|
+
Serve SETS X-Forwarded-For to the client's tailnet address and connects from 127.0.0.1; the shipped Caddyfile
|
|
257
|
+
trusts that loopback peer (`trusted_proxies 127.0.0.0/8` on the /enrol/* route), so Caddy keeps the header and
|
|
258
|
+
appends its peer — `<100.x client>, 127.0.0.1` — and each tailnet device is its own bucket. A Caddy that does not
|
|
259
|
+
trust the peer replaces the header with `127.0.0.1`: then every device shares one bucket (fail closed, DEF-1641).
|
|
260
|
+
`/site` echoes the result as `source`, the operator's live check."""
|
|
261
|
+
hops = [part.strip() for part in (forwarded or '').split(',') if part.strip()]
|
|
262
|
+
while hops:
|
|
263
|
+
try:
|
|
264
|
+
if not ipaddress.ip_address(hops[-1]).is_loopback:
|
|
265
|
+
break
|
|
266
|
+
except ValueError:
|
|
267
|
+
break
|
|
268
|
+
hops.pop()
|
|
269
|
+
return (hops[-1] if hops else peer)[:64]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _read_doc(path):
|
|
273
|
+
try:
|
|
274
|
+
document = json.loads(Path(path).read_bytes().decode('utf-8'))
|
|
275
|
+
except (OSError, UnicodeDecodeError, ValueError):
|
|
276
|
+
return None
|
|
277
|
+
return document if isinstance(document, dict) else None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _json_bytes(document):
|
|
281
|
+
try:
|
|
282
|
+
return (json.dumps(document, indent=2, ensure_ascii=False) + '\n').encode('utf-8')
|
|
283
|
+
except UnicodeEncodeError:
|
|
284
|
+
return (json.dumps(document, indent=2) + '\n').encode('ascii')
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _private_dir(directory):
|
|
288
|
+
directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
289
|
+
if directory.stat().st_mode & 0o777 != 0o700:
|
|
290
|
+
os.chmod(directory, 0o700)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _log_field(value):
|
|
294
|
+
return _LOG_UNSAFE.sub('?', str(value))[:128] if value else '-'
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def audit(event, ident, device, login, fingerprint, result, now=None):
|
|
298
|
+
"""Append one line to enrol.log (0600). Never raises: a log that cannot be written is said on stderr."""
|
|
299
|
+
line = ' '.join((stamp(time.time() if now is None else now), _log_field(event), _log_field(ident),
|
|
300
|
+
_log_field(device), _log_field(login), 'fp=' + _log_field(fingerprint), _log_field(result)))
|
|
301
|
+
try:
|
|
302
|
+
L.STATE.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
handle = os.open(log_path(), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
304
|
+
try:
|
|
305
|
+
os.write(handle, (line + '\n').encode('ascii', 'replace'))
|
|
306
|
+
finally:
|
|
307
|
+
os.close(handle)
|
|
308
|
+
except OSError as error:
|
|
309
|
+
print(f'lanes-enrold: cannot append to {log_path()}: {error.strerror or error}', file=sys.stderr, flush=True)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def report_dropped(event, ident, login, problems, now):
|
|
313
|
+
"""Before a write that re-renders the block from keys.load_members' valid members: each member file it could not
|
|
314
|
+
use (`problems`, [(file name, reason)]) is left out — fail closed, its line goes — and that is said, never silent
|
|
315
|
+
(DEF-1729): on stderr as `lanes keys` says it (keys._warn_problems), and one enrol.log line per file with its
|
|
316
|
+
device and the result `dropped-member`. The reasons are members.py's and keys.py's, which never echo a value."""
|
|
317
|
+
for name, reason in problems:
|
|
318
|
+
device = name[:-len('.json')] if name.endswith('.json') else name
|
|
319
|
+
print(f'lanes-enrold: ⚠ members/{_log_field(name)}: {reason} — NOT authorized (fix or remove the file); '
|
|
320
|
+
f'{event} {_log_field(ident)} re-rendered the lanes-managed block without it', file=sys.stderr,
|
|
321
|
+
flush=True)
|
|
322
|
+
audit(event, ident, device, login, None, 'dropped-member', now)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ----------------------------------------------------------------------------------------------------- identity
|
|
326
|
+
|
|
327
|
+
def _header_text(raw):
|
|
328
|
+
"""A header value as text: http.server hands values over as latin-1, Tailscale sends UTF-8 or RFC 2047."""
|
|
329
|
+
if raw is None:
|
|
330
|
+
return None
|
|
331
|
+
try:
|
|
332
|
+
text = raw.encode('latin-1').decode('utf-8')
|
|
333
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
334
|
+
return None
|
|
335
|
+
if '=?' in text:
|
|
336
|
+
try:
|
|
337
|
+
text = str(email.header.make_header(email.header.decode_header(text)))
|
|
338
|
+
except (ValueError, LookupError, UnicodeError, email.errors.HeaderParseError):
|
|
339
|
+
return None
|
|
340
|
+
return text.strip() or None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _valid_name(name):
|
|
344
|
+
if name is None:
|
|
345
|
+
return None
|
|
346
|
+
try:
|
|
347
|
+
return M.validate_owner({'login': 'x', 'name': name})['name']
|
|
348
|
+
except L.LanesError:
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _valid_email(value):
|
|
353
|
+
"""`value` when members.py would store it as an owner's email (an address, printable, no blanks), else None."""
|
|
354
|
+
if _address(value) is None:
|
|
355
|
+
return None
|
|
356
|
+
try:
|
|
357
|
+
return M.validate_owner({'login': 'x', 'email': value})['email']
|
|
358
|
+
except L.LanesError:
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def caller_of(headers):
|
|
363
|
+
"""The tailnet identity a request carries -> Caller(login, name, present). `present` = the header set Serve
|
|
364
|
+
injects (a non-empty login AND a name header, even an empty one); login/name are None when they do not validate
|
|
365
|
+
as members.py would store them."""
|
|
366
|
+
raw_login = headers.get(LOGIN_HEADER)
|
|
367
|
+
present = bool(raw_login and raw_login.strip()) and headers.get(NAME_HEADER) is not None
|
|
368
|
+
login = _header_text(raw_login)
|
|
369
|
+
try:
|
|
370
|
+
login = M.check_login(login) if login else None
|
|
371
|
+
except L.LanesError:
|
|
372
|
+
login = None
|
|
373
|
+
return Caller(login, _valid_name(_header_text(headers.get(NAME_HEADER))), present)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _owner(*candidates):
|
|
377
|
+
for candidate in candidates:
|
|
378
|
+
try:
|
|
379
|
+
return M.validate_owner(candidate)
|
|
380
|
+
except L.LanesError:
|
|
381
|
+
continue
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _address(login):
|
|
386
|
+
return login if isinstance(login, str) and _ADDRESS.fullmatch(login) else None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _valid_login(value):
|
|
390
|
+
try:
|
|
391
|
+
return M.check_login(value) if value is not None else None
|
|
392
|
+
except L.LanesError:
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _workspaces(value):
|
|
397
|
+
if not isinstance(value, list):
|
|
398
|
+
return []
|
|
399
|
+
kept = []
|
|
400
|
+
for workspace in value:
|
|
401
|
+
if isinstance(workspace, str) and L.IDENT.match(workspace) and len(workspace) <= M.MAX_ID and workspace not in kept:
|
|
402
|
+
kept.append(workspace)
|
|
403
|
+
return kept
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------------------------------------------ invites
|
|
407
|
+
|
|
408
|
+
def load_invites():
|
|
409
|
+
"""[(id, path, document)] for every readable invites/<id>.json whose stem is an id."""
|
|
410
|
+
found = []
|
|
411
|
+
for path in sorted(invites_dir().glob('*.json')):
|
|
412
|
+
if not L.IDENT.match(path.stem) or len(path.stem) > M.MAX_ID:
|
|
413
|
+
continue
|
|
414
|
+
document = _read_doc(path)
|
|
415
|
+
if document is not None:
|
|
416
|
+
found.append((path.stem, path, document))
|
|
417
|
+
return found
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def invite_live(document, now):
|
|
421
|
+
"""Usable now: a well-formed hash, not used, not revoked, not past expires_at (an unreadable expiry is past)."""
|
|
422
|
+
expires = parse_stamp(document.get('expires_at'))
|
|
423
|
+
return (isinstance(document.get('code_sha256'), str) and _HEX64.fullmatch(document['code_sha256']) is not None
|
|
424
|
+
and document.get('redeemed_by') is None and document.get('revoked_at') is None
|
|
425
|
+
and expires is not None and now < expires)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def match_invite(code, now):
|
|
429
|
+
"""(id, path, digest) of the live invite whose hash is sha256(code), else None. The digest is compared against
|
|
430
|
+
EVERY live invite (hmac.compare_digest, the loop never stops early), so the time taken says nothing about which
|
|
431
|
+
invite, if any, matched; dead invites are never candidates, so expired/revoked/used look like unknown."""
|
|
432
|
+
presented = canonical_code(code) if isinstance(code, str) and len(code) <= MAX_CODE and code.isascii() else ''
|
|
433
|
+
digest = hashlib.sha256(presented.encode('ascii')).hexdigest()
|
|
434
|
+
found = None
|
|
435
|
+
for invite_id, path, document in load_invites():
|
|
436
|
+
if invite_live(document, now) and hmac.compare_digest(digest, document['code_sha256']) and found is None:
|
|
437
|
+
found = (invite_id, path, digest)
|
|
438
|
+
return found if presented else None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def minter_login(invite):
|
|
442
|
+
"""The login that minted `invite`, as the member's `approved_by`, or None. `lanes invite` (A4) writes `minted_by`
|
|
443
|
+
as {device, login, unix_user} with `login` null: over SSH every login is the one Unix account, so the CLI claims
|
|
444
|
+
no tailnet login, and an invite it minted yields a member approved by nobody named (DEF-1646)."""
|
|
445
|
+
minted = invite.get('minted_by')
|
|
446
|
+
return _valid_login(minted.get('login')) if isinstance(minted, dict) else None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def invite_owner(invite, caller):
|
|
450
|
+
"""The member owner a redeemed invite records, or None.
|
|
451
|
+
With a TRUSTED caller (request mode: a validated tailnet login) the member is the caller's: login, email (the login
|
|
452
|
+
when it is an address) and display name all come from the caller, never from the invite — an invite minted for
|
|
453
|
+
anna@ and redeemed by otto@ is otto@'s device, and the invite's own name and email go to `invited_as` beside it
|
|
454
|
+
(invited_as; DEF-1732). Without one, the login comes only from something that IS a login: the invite's email when
|
|
455
|
+
it is an address, else the invite's name when it is itself a login (a handle such as `anna`). A display name such
|
|
456
|
+
as "Anna Berg" is never a login (members.check_login refuses blanks), so an invite that names only that records no
|
|
457
|
+
owner: the member keeps its invite_id, the invite file keeps the name, and `lanes keys owner` names the owner later
|
|
458
|
+
(DEF-1644)."""
|
|
459
|
+
if caller.login:
|
|
460
|
+
login, email = caller.login, _address(caller.login)
|
|
461
|
+
return _owner({'login': login, 'name': caller.name, 'email': email},
|
|
462
|
+
{'login': login, 'name': None, 'email': email}, {'login': login, 'name': None, 'email': None})
|
|
463
|
+
login = next((value for value in (_address(invite.get('email')), invite.get('name')) if _valid_login(value)), None)
|
|
464
|
+
if login is None:
|
|
465
|
+
return None
|
|
466
|
+
name = invite.get('name') or caller.name
|
|
467
|
+
email = _address(invite.get('email'))
|
|
468
|
+
return _owner({'login': login, 'name': name, 'email': email}, {'login': login, 'name': name, 'email': None},
|
|
469
|
+
{'login': login, 'name': None, 'email': None})
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def invited_as(invite):
|
|
473
|
+
"""The invite's own {name, email} as a member records it beside a trusted caller's owner (`invited_as`): each the
|
|
474
|
+
value members.py would store, else null — an invite file's text never reaches a member record unchecked."""
|
|
475
|
+
return {'name': _valid_name(invite.get('name')), 'email': _valid_email(invite.get('email'))}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ---------------------------------------------------------------------------------------------- shared checks
|
|
479
|
+
|
|
480
|
+
def device_taken(device, data):
|
|
481
|
+
"""Whether `device` already names a member, a view device, or a `lanes:<device>` line in or outside the block of
|
|
482
|
+
authorized_keys `data` (malformed markers raise L.LanesError)."""
|
|
483
|
+
if (K.members_dir() / f'{device}.json').exists() or (L.STATE / 'devices' / f'{device}.json').exists():
|
|
484
|
+
return True
|
|
485
|
+
if K.block_entries(data, device):
|
|
486
|
+
return True
|
|
487
|
+
return any(K._lanes_device(parsed) == device for _, _, parsed in K.outside_entries(data))
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _authorized_keys():
|
|
491
|
+
"""(path, bytes) of authorized_keys, its markers checked: raises L.LanesError (malformed) or OSError."""
|
|
492
|
+
path = K.authorized_keys_path()
|
|
493
|
+
data = K._read(path)
|
|
494
|
+
K.split_block(data)
|
|
495
|
+
return path, data
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _block_lacks(path, members, device):
|
|
499
|
+
"""After a failed block write: re-render without `device` (best effort) and read back whether the block is free of
|
|
500
|
+
it. True only when that is known — the caller then may drop the member file without leaving a line behind it."""
|
|
501
|
+
try:
|
|
502
|
+
K.write_authorized_keys(path, members)
|
|
503
|
+
except (OSError, L.LanesError):
|
|
504
|
+
pass
|
|
505
|
+
try:
|
|
506
|
+
return not K.block_entries(K._read(path), device)
|
|
507
|
+
except (OSError, L.LanesError):
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ------------------------------------------------------------------------------------------------------- redeem
|
|
512
|
+
|
|
513
|
+
def redeem(fields, caller=None, now=None):
|
|
514
|
+
"""POST /redeem -> (status, payload). `caller` is the TRUSTED tailnet identity (None outside request mode)."""
|
|
515
|
+
now = time.time() if now is None else now
|
|
516
|
+
caller = caller or NOBODY
|
|
517
|
+
try:
|
|
518
|
+
device = M.device_name(fields.get('device'))
|
|
519
|
+
except L.LanesError:
|
|
520
|
+
audit('redeem', None, None, caller.login, None, 'invalid-device', now)
|
|
521
|
+
return 400, {'error': 'invalid-device'}
|
|
522
|
+
try:
|
|
523
|
+
pubkey = K.parse_pubkey(fields['pubkey']) if isinstance(fields.get('pubkey'), str) else None
|
|
524
|
+
except L.LanesError:
|
|
525
|
+
pubkey = None
|
|
526
|
+
fingerprint = K.fingerprint(pubkey) if pubkey else None
|
|
527
|
+
match = match_invite(fields.get('code'), now)
|
|
528
|
+
if match is None or pubkey is None:
|
|
529
|
+
audit('redeem', None, device, caller.login, fingerprint, INVALID, now)
|
|
530
|
+
return 403, {'error': INVALID}
|
|
531
|
+
try:
|
|
532
|
+
with K.members_lock():
|
|
533
|
+
return _redeem_locked(match, device, pubkey, fingerprint, caller, now)
|
|
534
|
+
except L.LanesError: # only the lock raises out of _redeem_locked
|
|
535
|
+
audit('redeem', match[0], device, caller.login, fingerprint, 'busy', now)
|
|
536
|
+
return 503, {'error': 'busy'}
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _redeem_locked(match, device, pubkey, fingerprint, caller, now):
|
|
540
|
+
invite_id, path, digest = match
|
|
541
|
+
invite = _read_doc(path)
|
|
542
|
+
if invite is None or not invite_live(invite, now) or not hmac.compare_digest(digest, invite['code_sha256']):
|
|
543
|
+
audit('redeem', None, device, caller.login, fingerprint, INVALID, now) # used while we waited
|
|
544
|
+
return 403, {'error': INVALID}
|
|
545
|
+
try:
|
|
546
|
+
ak, data = _authorized_keys()
|
|
547
|
+
taken = device_taken(device, data)
|
|
548
|
+
members, problems = K.load_members()
|
|
549
|
+
owner = invite_owner(invite, caller)
|
|
550
|
+
record = K.member_record(device, pubkey, via='invite', now=now, owner=owner,
|
|
551
|
+
approved_by=minter_login(invite), invite_id=invite_id,
|
|
552
|
+
workspaces=_workspaces(invite.get('workspaces')),
|
|
553
|
+
**({'invited_as': invited_as(invite)} if caller.login else {}))
|
|
554
|
+
original = path.read_bytes()
|
|
555
|
+
except (OSError, L.LanesError) as error:
|
|
556
|
+
print(f'lanes-enrold: redeem {invite_id}: {error}', file=sys.stderr, flush=True)
|
|
557
|
+
audit('redeem', invite_id, device, caller.login, fingerprint, 'server-error', now)
|
|
558
|
+
return 503, {'error': 'server-error'}
|
|
559
|
+
who = owner['login'] if owner else caller.login
|
|
560
|
+
if taken:
|
|
561
|
+
audit('redeem', invite_id, device, who, fingerprint, 'device-taken', now)
|
|
562
|
+
return 409, {'error': 'device-taken'}
|
|
563
|
+
if any(member['pubkey'] == pubkey for member in members):
|
|
564
|
+
audit('redeem', invite_id, device, who, fingerprint, 'key-taken', now)
|
|
565
|
+
return 409, {'error': 'key-taken'}
|
|
566
|
+
consumed = dict(invite, redeemed_by={'device': device, 'login': who, 'pubkey_sha256': fingerprint},
|
|
567
|
+
redeemed_at=stamp(now))
|
|
568
|
+
try:
|
|
569
|
+
L.atomic_write(path, _json_bytes(consumed), mode=0o600) # used up FIRST: a crash never leaves it reusable
|
|
570
|
+
except OSError as error:
|
|
571
|
+
print(f'lanes-enrold: cannot mark invite {invite_id} used: {error.strerror}', file=sys.stderr, flush=True)
|
|
572
|
+
audit('redeem', invite_id, device, who, fingerprint, 'server-error', now)
|
|
573
|
+
return 503, {'error': 'server-error'}
|
|
574
|
+
report_dropped('redeem', invite_id, who, problems, now) # the writes below re-render the block
|
|
575
|
+
try:
|
|
576
|
+
K.save_member(record) # the record before its line (keys.py's order)
|
|
577
|
+
K.write_authorized_keys(ak, members + [record])
|
|
578
|
+
except (OSError, L.LanesError) as error:
|
|
579
|
+
print(f'lanes-enrold: redeem {invite_id} for {device}: {error}', file=sys.stderr, flush=True)
|
|
580
|
+
if _block_lacks(ak, members, device): # nothing lets it in: undo the record and the use
|
|
581
|
+
try:
|
|
582
|
+
(K.members_dir() / f'{device}.json').unlink(missing_ok=True)
|
|
583
|
+
L.atomic_write(path, original, mode=0o600)
|
|
584
|
+
except OSError as undo:
|
|
585
|
+
print(f'lanes-enrold: undo of {invite_id} incomplete: {undo.strerror}', file=sys.stderr, flush=True)
|
|
586
|
+
audit('redeem', invite_id, device, who, fingerprint, 'server-error', now)
|
|
587
|
+
return 503, {'error': 'server-error'}
|
|
588
|
+
if owner is None: # said, never silent (DEF-1644)
|
|
589
|
+
print(f'lanes-enrold: invite {invite_id} names no login (no trusted tailnet login, no email address, and its '
|
|
590
|
+
f'name is not a login): members/{device}.json has no owner — `lanes keys owner {device} <login>` '
|
|
591
|
+
'names one', file=sys.stderr, flush=True)
|
|
592
|
+
audit('redeem', invite_id, device, who, fingerprint, 'ok', now)
|
|
593
|
+
return 200, {'device': device, 'owner': owner, 'ssh_ready': True}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# ----------------------------------------------------------------------------------------------------- requests
|
|
597
|
+
|
|
598
|
+
def _valid_request(document, stem):
|
|
599
|
+
try:
|
|
600
|
+
return bool(document.get('id') == stem and _REQUEST_ID.fullmatch(stem) and document.get('state') in STATES
|
|
601
|
+
and M.device_name(document.get('device'))
|
|
602
|
+
and M.validate_owner({'login': document.get('login'), 'name': document.get('name'),
|
|
603
|
+
'email': document.get('email')})
|
|
604
|
+
and isinstance(document.get('pubkey'), str)
|
|
605
|
+
and K.parse_pubkey(document['pubkey']) == document['pubkey']
|
|
606
|
+
and parse_stamp(document.get('expires_at')) is not None)
|
|
607
|
+
except L.LanesError:
|
|
608
|
+
return False
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def load_requests(directory=None):
|
|
612
|
+
"""Every valid enrol-requests/<id>.json, oldest first (invalid files are skipped). `directory` defaults to
|
|
613
|
+
requests_dir(); notify.py (W8 A5) passes the enrol-requests/ of the STATE it watches."""
|
|
614
|
+
found = []
|
|
615
|
+
for path in sorted(Path(directory if directory is not None else requests_dir()).glob('*.json')):
|
|
616
|
+
document = _read_doc(path)
|
|
617
|
+
if document is not None and _valid_request(document, path.stem):
|
|
618
|
+
found.append(document)
|
|
619
|
+
return sorted(found, key=lambda document: (document.get('created_at') or '', document['id']))
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def read_request(request_id):
|
|
623
|
+
"""The valid request `request_id` (case-insensitive), else None."""
|
|
624
|
+
if not isinstance(request_id, str):
|
|
625
|
+
return None
|
|
626
|
+
request_id = request_id.lower()
|
|
627
|
+
if not _REQUEST_ID.fullmatch(request_id):
|
|
628
|
+
return None
|
|
629
|
+
document = _read_doc(requests_dir() / f'{request_id}.json')
|
|
630
|
+
return document if document is not None and _valid_request(document, request_id) else None
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def request_state(document, now):
|
|
634
|
+
if document['state'] == 'pending' and now >= parse_stamp(document['expires_at']):
|
|
635
|
+
return 'expired'
|
|
636
|
+
return document['state']
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _write_request(document):
|
|
640
|
+
_private_dir(requests_dir())
|
|
641
|
+
L.atomic_write(requests_dir() / f"{document['id']}.json", _json_bytes(document), mode=0o600)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _new_request_id():
|
|
645
|
+
while True:
|
|
646
|
+
candidate = ''.join(secrets.choice(CODE_ALPHABET) for _ in range(REQUEST_ID_LENGTH))
|
|
647
|
+
if not (requests_dir() / f'{candidate}.json').exists():
|
|
648
|
+
return candidate
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def create_request(fields, caller, now=None):
|
|
652
|
+
"""POST /request -> (status, payload). `caller` is the TRUSTED identity: the HTTP layer has checked request mode,
|
|
653
|
+
the login and the domain."""
|
|
654
|
+
now = time.time() if now is None else now
|
|
655
|
+
login = caller.login
|
|
656
|
+
try:
|
|
657
|
+
device = M.device_name(fields.get('device'))
|
|
658
|
+
except L.LanesError:
|
|
659
|
+
audit('request', None, None, login, None, 'invalid-device', now)
|
|
660
|
+
return 400, {'error': 'invalid-device'}
|
|
661
|
+
try:
|
|
662
|
+
pubkey = K.parse_pubkey(fields['pubkey']) if isinstance(fields.get('pubkey'), str) else None
|
|
663
|
+
except L.LanesError:
|
|
664
|
+
pubkey = None
|
|
665
|
+
if pubkey is None:
|
|
666
|
+
audit('request', None, device, login, None, 'invalid-pubkey', now)
|
|
667
|
+
return 400, {'error': 'invalid-pubkey'}
|
|
668
|
+
fingerprint = K.fingerprint(pubkey)
|
|
669
|
+
name = fields.get('name')
|
|
670
|
+
if name is not None:
|
|
671
|
+
try:
|
|
672
|
+
name = M.validate_owner({'login': login, 'name': L._title(name)})['name']
|
|
673
|
+
except L.LanesError:
|
|
674
|
+
audit('request', None, device, login, fingerprint, 'invalid-name', now)
|
|
675
|
+
return 400, {'error': 'invalid-name'}
|
|
676
|
+
owner = _owner({'login': login, 'name': name or caller.name, 'email': _address(login)},
|
|
677
|
+
{'login': login, 'name': None, 'email': _address(login)})
|
|
678
|
+
if owner is None:
|
|
679
|
+
return 403, {'error': 'no-identity'}
|
|
680
|
+
client = fields.get('client_version')
|
|
681
|
+
client = client if isinstance(client, str) and _CLIENT_VERSION.fullmatch(client) else None
|
|
682
|
+
try:
|
|
683
|
+
with K.members_lock():
|
|
684
|
+
pending = [r for r in load_requests() if request_state(r, now) == 'pending']
|
|
685
|
+
mine = [r for r in pending if r['login'].casefold() == login.casefold()]
|
|
686
|
+
for request in mine:
|
|
687
|
+
if request['device'] == device and request['pubkey'] == pubkey:
|
|
688
|
+
return 202, {'id': request['id'], 'expires_at': request['expires_at']}
|
|
689
|
+
if len(mine) >= OPEN_REQUESTS:
|
|
690
|
+
audit('request', None, device, login, fingerprint, 'too-many-requests', now)
|
|
691
|
+
return 429, {'error': 'too-many-requests'}
|
|
692
|
+
try:
|
|
693
|
+
_, data = _authorized_keys()
|
|
694
|
+
taken = device_taken(device, data) or any(r['device'] == device for r in pending)
|
|
695
|
+
members, _problems = K.load_members()
|
|
696
|
+
except (OSError, L.LanesError) as error:
|
|
697
|
+
print(f'lanes-enrold: request for {device}: {error}', file=sys.stderr, flush=True)
|
|
698
|
+
audit('request', None, device, login, fingerprint, 'server-error', now)
|
|
699
|
+
return 503, {'error': 'server-error'}
|
|
700
|
+
if taken:
|
|
701
|
+
audit('request', None, device, login, fingerprint, 'device-taken', now)
|
|
702
|
+
return 409, {'error': 'device-taken'}
|
|
703
|
+
if any(m['pubkey'] == pubkey for m in members) or any(r['pubkey'] == pubkey for r in pending):
|
|
704
|
+
audit('request', None, device, login, fingerprint, 'key-taken', now)
|
|
705
|
+
return 409, {'error': 'key-taken'}
|
|
706
|
+
document = {'id': _new_request_id(), 'state': 'pending', 'login': owner['login'], 'name': owner['name'],
|
|
707
|
+
'email': owner['email'], 'device': device, 'pubkey': pubkey, 'pubkey_sha256': fingerprint,
|
|
708
|
+
'client_version': client, 'created_at': stamp(now), 'expires_at': stamp(now + REQUEST_TTL),
|
|
709
|
+
'decided_by': None, 'decided_at': None}
|
|
710
|
+
try:
|
|
711
|
+
_write_request(document)
|
|
712
|
+
except OSError as error:
|
|
713
|
+
print(f'lanes-enrold: cannot write a request: {error.strerror}', file=sys.stderr, flush=True)
|
|
714
|
+
audit('request', None, device, login, fingerprint, 'server-error', now)
|
|
715
|
+
return 503, {'error': 'server-error'}
|
|
716
|
+
except L.LanesError: # the lock
|
|
717
|
+
return 503, {'error': 'busy'}
|
|
718
|
+
audit('request', document['id'], device, login, fingerprint, 'pending', now)
|
|
719
|
+
return 202, {'id': document['id'], 'expires_at': document['expires_at']}
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def decide(request_id, action, approver, now=None):
|
|
723
|
+
"""Approve or deny a pending request as `approver` (a login) -> the updated request document. Approval writes the
|
|
724
|
+
member (via request, approved_by) and its line through keys.py. Raises L.LanesError with a sentence on every
|
|
725
|
+
refusal: no such request, not pending, the device or key taken since, a write that failed (nothing is left
|
|
726
|
+
authorized by a failed approval). `lanes enrol approve|deny` (A4) and POST /pending both call this."""
|
|
727
|
+
now = time.time() if now is None else now
|
|
728
|
+
if action not in ACTIONS:
|
|
729
|
+
raise L.LanesError(f'unknown action {action!r}: approve or deny')
|
|
730
|
+
approver = M.check_login(approver, 'approver')
|
|
731
|
+
shown = request_id.lower() if isinstance(request_id, str) and _REQUEST_ID.fullmatch(request_id.lower()) else '?'
|
|
732
|
+
with K.members_lock():
|
|
733
|
+
document = read_request(request_id)
|
|
734
|
+
if document is None:
|
|
735
|
+
raise L.LanesError(f'no enrolment request {shown}')
|
|
736
|
+
try:
|
|
737
|
+
state = request_state(document, now)
|
|
738
|
+
if state != 'pending':
|
|
739
|
+
raise L.LanesError(f"request {document['id']} is {state}, not pending")
|
|
740
|
+
if action == 'approve':
|
|
741
|
+
_approve_locked(document, approver, now)
|
|
742
|
+
document = dict(document, state='approved' if action == 'approve' else 'denied', decided_by=approver,
|
|
743
|
+
decided_at=stamp(now))
|
|
744
|
+
try:
|
|
745
|
+
_write_request(document)
|
|
746
|
+
except OSError as error:
|
|
747
|
+
raise L.LanesError(f"request {document['id']}: the decision could not be recorded "
|
|
748
|
+
f'({error.strerror}); ' + ('the key IS authorized — run the approval again to '
|
|
749
|
+
'record it' if action == 'approve' else
|
|
750
|
+
'run the denial again')) from None
|
|
751
|
+
except L.LanesError as error:
|
|
752
|
+
audit(action, document['id'], document['device'], approver, document['pubkey_sha256'], 'refused', now)
|
|
753
|
+
raise error
|
|
754
|
+
audit(action, document['id'], document['device'], approver, document['pubkey_sha256'], 'ok', now)
|
|
755
|
+
return document
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _approve_locked(document, approver, now):
|
|
759
|
+
device, pubkey = document['device'], document['pubkey']
|
|
760
|
+
try:
|
|
761
|
+
ak, data = _authorized_keys()
|
|
762
|
+
except OSError as error:
|
|
763
|
+
raise L.LanesError(f'cannot read authorized_keys ({error.strerror}); nothing changed') from None
|
|
764
|
+
members, problems = K.load_members()
|
|
765
|
+
current = next((m for m in members if m['device'] == device), None)
|
|
766
|
+
if current is not None and current['pubkey'] == pubkey and current.get('request_id') == document['id']:
|
|
767
|
+
report_dropped('approve', document['id'], approver, problems, now)
|
|
768
|
+
try:
|
|
769
|
+
K.write_authorized_keys(ak, members) # an approval interrupted after its member write: finish it
|
|
770
|
+
except OSError as error:
|
|
771
|
+
raise L.LanesError(f'cannot write {ak} ({error.strerror}); members/{device}.json is kept — the same '
|
|
772
|
+
'approval again finishes it') from None
|
|
773
|
+
return
|
|
774
|
+
if device_taken(device, data):
|
|
775
|
+
raise L.LanesError(f'device {device} is already enrolled or in use since the request; deny it and ask for '
|
|
776
|
+
'another device name')
|
|
777
|
+
holder = next((m['device'] for m in members if m['pubkey'] == pubkey), None)
|
|
778
|
+
if holder:
|
|
779
|
+
raise L.LanesError(f'that key is already enrolled as {holder}')
|
|
780
|
+
owner = M.validate_owner({'login': document['login'], 'name': document.get('name'),
|
|
781
|
+
'email': document.get('email')})
|
|
782
|
+
record = K.member_record(device, pubkey, via='request', now=now, owner=owner, approved_by=approver,
|
|
783
|
+
request_id=document['id'])
|
|
784
|
+
try:
|
|
785
|
+
K.save_member(record)
|
|
786
|
+
except OSError as error:
|
|
787
|
+
(K.members_dir() / f'{device}.json').unlink(missing_ok=True)
|
|
788
|
+
raise L.LanesError(f'cannot write members/{device}.json ({error.strerror}); nothing was authorized') from None
|
|
789
|
+
report_dropped('approve', document['id'], approver, problems, now) # the write below re-renders the block
|
|
790
|
+
try:
|
|
791
|
+
K.write_authorized_keys(ak, members + [record])
|
|
792
|
+
except (OSError, L.LanesError) as error:
|
|
793
|
+
reason = getattr(error, 'strerror', None) or str(error)
|
|
794
|
+
if _block_lacks(ak, members, device):
|
|
795
|
+
(K.members_dir() / f'{device}.json').unlink(missing_ok=True)
|
|
796
|
+
raise L.LanesError(f'cannot write {ak} ({reason}); nothing was authorized') from None
|
|
797
|
+
raise L.LanesError(f'cannot write {ak} ({reason}); members/{device}.json is kept — `lanes keys revoke '
|
|
798
|
+
f'{device}` or the same approval again finishes it') from None
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def member_logins():
|
|
802
|
+
"""The logins (case-folded) that own a valid member record: who may see and decide requests."""
|
|
803
|
+
members, _problems = K.load_members()
|
|
804
|
+
return {m['owner']['login'].casefold() for m in members if isinstance(m.get('owner'), dict)}
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# ------------------------------------------------------------------------------------------------------- /site
|
|
808
|
+
|
|
809
|
+
_TAILNET = {'at': None, 'name': None}
|
|
810
|
+
_TAILNET_LOCK = threading.Lock()
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def server_site():
|
|
814
|
+
"""The server's own ~/.config/lanes/site.conf LANES_* facts (the file `lanes client-config` serves)."""
|
|
815
|
+
try:
|
|
816
|
+
lines = (Path.home() / '.config/lanes/site.conf').read_text().splitlines()
|
|
817
|
+
except (OSError, UnicodeDecodeError):
|
|
818
|
+
return {}
|
|
819
|
+
return dict(found.groups() for found in map(_SITE_LINE.match, lines) if found)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def tailnet_name():
|
|
823
|
+
"""This node's MagicDNS name from `tailscale status --json` (bounded, cached for TAILNET_TTL s), else None."""
|
|
824
|
+
with _TAILNET_LOCK:
|
|
825
|
+
if _TAILNET['at'] is not None and time.monotonic() - _TAILNET['at'] < TAILNET_TTL:
|
|
826
|
+
return _TAILNET['name']
|
|
827
|
+
name = None
|
|
828
|
+
if shutil.which('tailscale'):
|
|
829
|
+
code, out, _err = L.run_bounded(['tailscale', 'status', '--json'], 10)
|
|
830
|
+
if code == 0:
|
|
831
|
+
try:
|
|
832
|
+
name = ((json.loads(out).get('Self') or {}).get('DNSName') or '').rstrip('.') or None
|
|
833
|
+
except (ValueError, AttributeError, TypeError):
|
|
834
|
+
name = None
|
|
835
|
+
_TAILNET.update(at=time.monotonic(), name=name)
|
|
836
|
+
return name
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def host_key():
|
|
840
|
+
"""The server's ed25519 host key as `ssh-ed25519 <base64>` (comment dropped), best effort: None when absent."""
|
|
841
|
+
try:
|
|
842
|
+
return K.parse_pubkey(HOST_KEY.read_text()[:K.MAX_PUBKEY_FILE])
|
|
843
|
+
except (OSError, UnicodeDecodeError, L.LanesError):
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def site_facts(caller, request_mode, source=None):
|
|
848
|
+
site = server_site()
|
|
849
|
+
return {'alias': site.get('LANES_SERVER_ALIAS') or socket.gethostname().split('.')[0] or None,
|
|
850
|
+
'host_name': site.get('LANES_SERVER_NAME') or tailnet_name(),
|
|
851
|
+
'host_key': host_key(), 'version': version(), 'request_mode': request_mode, 'caller': caller.login,
|
|
852
|
+
'source': source}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
# --------------------------------------------------------------------------------------------------- the service
|
|
856
|
+
|
|
857
|
+
class Service:
|
|
858
|
+
"""Process-wide state: configuration, the request-mode latch, the redeem rate limiter and the form secret."""
|
|
859
|
+
|
|
860
|
+
def __init__(self, domains=None, request_mode=None, clock=time.time):
|
|
861
|
+
self.domains = tuple(domains) if domains else DEFAULT_DOMAINS
|
|
862
|
+
self.forced = request_mode
|
|
863
|
+
self.clock = clock
|
|
864
|
+
self.secret = secrets.token_bytes(32)
|
|
865
|
+
self._first = None
|
|
866
|
+
self._pin_problem = None
|
|
867
|
+
self._lock = threading.Lock()
|
|
868
|
+
self._attempts = {}
|
|
869
|
+
self._tokens = itertools.count(1)
|
|
870
|
+
|
|
871
|
+
@classmethod
|
|
872
|
+
def from_environ(cls, environ):
|
|
873
|
+
forced = {'1': True, '0': False}.get((environ.get('LANES_ENROL_REQUEST_MODE') or '').strip())
|
|
874
|
+
return cls(domains=parse_domains(environ.get('LANES_ENROL_DOMAINS')), request_mode=forced)
|
|
875
|
+
|
|
876
|
+
def observe(self, caller, via_serve=True):
|
|
877
|
+
"""The auto latch: the first request that came through Serve (through_serve) decides, for the life of the
|
|
878
|
+
process, whether Serve injects identity. Anything else — a loopback probe — decides nothing."""
|
|
879
|
+
if not via_serve:
|
|
880
|
+
return
|
|
881
|
+
with self._lock:
|
|
882
|
+
if self._first is None:
|
|
883
|
+
self._first = bool(caller.present)
|
|
884
|
+
|
|
885
|
+
@property
|
|
886
|
+
def request_mode(self):
|
|
887
|
+
"""The unit's LANES_ENROL_REQUEST_MODE, else the pin `lanes enrol mode` wrote, else the latch."""
|
|
888
|
+
if self.forced is not None:
|
|
889
|
+
return self.forced
|
|
890
|
+
pinned = self.pinned()
|
|
891
|
+
return pinned == 'request' if pinned is not None else bool(self._first)
|
|
892
|
+
|
|
893
|
+
def pinned(self):
|
|
894
|
+
mode, problem = read_mode()
|
|
895
|
+
if problem and problem != self._pin_problem: # said once per new problem, not on every request
|
|
896
|
+
print(f'lanes-enrold: {problem}: requests are refused until `lanes enrol mode request|invite|auto` '
|
|
897
|
+
'writes it again', file=sys.stderr, flush=True)
|
|
898
|
+
self._pin_problem = problem
|
|
899
|
+
return mode
|
|
900
|
+
|
|
901
|
+
def mode_source(self):
|
|
902
|
+
"""How request mode is decided now, in words (the start line; `lanes enrol mode` says the same)."""
|
|
903
|
+
if self.forced is not None:
|
|
904
|
+
return f"{'on' if self.forced else 'off'} (LANES_ENROL_REQUEST_MODE in the environment)"
|
|
905
|
+
pinned = self.pinned()
|
|
906
|
+
if pinned is not None:
|
|
907
|
+
return f"{'on' if pinned == 'request' else 'off'} (pinned: {mode_path()})"
|
|
908
|
+
return {None: 'decided by the first request through Tailscale Serve', True: 'on (the first request through '
|
|
909
|
+
'Serve carried an identity)', False: 'off (the first request through Serve carried no identity)'}[
|
|
910
|
+
self._first]
|
|
911
|
+
|
|
912
|
+
def domain_ok(self, login):
|
|
913
|
+
if '*' in self.domains:
|
|
914
|
+
return True
|
|
915
|
+
return '@' in login and login.rpartition('@')[2].lower() in self.domains
|
|
916
|
+
|
|
917
|
+
def gate(self, caller):
|
|
918
|
+
"""None when `caller` may file or decide requests, else the refusal's error word."""
|
|
919
|
+
if not self.request_mode:
|
|
920
|
+
return 'requests-disabled'
|
|
921
|
+
if not caller.login:
|
|
922
|
+
return 'no-identity'
|
|
923
|
+
if not self.domain_ok(caller.login):
|
|
924
|
+
return 'domain-not-allowed'
|
|
925
|
+
return None
|
|
926
|
+
|
|
927
|
+
# The limiter counts an attempt when it STARTS (check and count are one step, so a burst cannot run five wrong
|
|
928
|
+
# codes in parallel past it) and forgets it when it succeeds or ends in a 409, which only a valid code reaches.
|
|
929
|
+
def _recent(self, source, now):
|
|
930
|
+
kept = [entry for entry in self._attempts.get(source, ()) if now - entry[1] < REDEEM_WINDOW]
|
|
931
|
+
if kept:
|
|
932
|
+
self._attempts[source] = kept
|
|
933
|
+
else:
|
|
934
|
+
self._attempts.pop(source, None)
|
|
935
|
+
return kept
|
|
936
|
+
|
|
937
|
+
def attempt(self, source):
|
|
938
|
+
"""-> (token, 0) when `source` may try a code now, else (None, seconds to wait)."""
|
|
939
|
+
now = self.clock()
|
|
940
|
+
with self._lock:
|
|
941
|
+
recent = self._recent(source, now)
|
|
942
|
+
if len(recent) >= REDEEM_FAILURES:
|
|
943
|
+
return None, int(recent[0][1] + REDEEM_WINDOW - now) + 1
|
|
944
|
+
token = next(self._tokens)
|
|
945
|
+
self._attempts[source] = recent + [(token, now)]
|
|
946
|
+
if len(self._attempts) > MAX_SOURCES:
|
|
947
|
+
for other in list(self._attempts):
|
|
948
|
+
self._recent(other, now)
|
|
949
|
+
while len(self._attempts) > MAX_SOURCES:
|
|
950
|
+
self._attempts.pop(min(self._attempts, key=lambda key: self._attempts[key][-1][1]))
|
|
951
|
+
return token, 0
|
|
952
|
+
|
|
953
|
+
def succeeded(self, source, token):
|
|
954
|
+
with self._lock:
|
|
955
|
+
kept = [entry for entry in self._attempts.get(source, ()) if entry[0] != token]
|
|
956
|
+
if kept:
|
|
957
|
+
self._attempts[source] = kept
|
|
958
|
+
else:
|
|
959
|
+
self._attempts.pop(source, None)
|
|
960
|
+
|
|
961
|
+
def token(self, login, request_id):
|
|
962
|
+
"""The form token for `login` deciding `request_id` (HMAC under this process's secret)."""
|
|
963
|
+
message = f'{login.casefold()}\0{request_id}'.encode('utf-8')
|
|
964
|
+
return hmac.new(self.secret, message, hashlib.sha256).hexdigest()[:32]
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
# ---------------------------------------------------------------------------------------------------- the page
|
|
968
|
+
|
|
969
|
+
def pending_page(service, caller, now, message=None):
|
|
970
|
+
rows = []
|
|
971
|
+
for request in load_requests():
|
|
972
|
+
if request_state(request, now) != 'pending':
|
|
973
|
+
continue
|
|
974
|
+
e = {key: html.escape(str(request.get(key) or '-')) for key in
|
|
975
|
+
('id', 'login', 'name', 'device', 'pubkey_sha256', 'created_at', 'expires_at', 'client_version')}
|
|
976
|
+
rows.append(
|
|
977
|
+
f'<li><p><b>{e["name"]}</b> <{e["login"]}><br>device <code>{e["device"]}</code> · key '
|
|
978
|
+
f'<code>{e["pubkey_sha256"]}</code><br><small>asked {e["created_at"]} · expires {e["expires_at"]} · '
|
|
979
|
+
f'client {e["client_version"]} · request <code>{e["id"]}</code></small></p>'
|
|
980
|
+
f'<form method="post" action="pending"><input type="hidden" name="id" value="{e["id"]}">'
|
|
981
|
+
f'<input type="hidden" name="token" value="{service.token(caller.login, request["id"])}">'
|
|
982
|
+
'<button name="action" value="approve">Approve</button> '
|
|
983
|
+
'<button name="action" value="deny">Deny</button></form></li>')
|
|
984
|
+
body = f'<ul>{"".join(rows)}</ul>' if rows else '<p>No pending requests.</p>'
|
|
985
|
+
note = f'<p class="note">{html.escape(message)}</p>' if message else ''
|
|
986
|
+
return _page('Enrolment requests', f'<p class="who">Signed in as {html.escape(caller.login)}</p>{note}{body}'
|
|
987
|
+
'<p class="who">On the server: <code>lanes enrol list</code> · <code>lanes enrol approve '
|
|
988
|
+
'<id></code></p>')
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _page(title, content):
|
|
992
|
+
return ('<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
993
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
994
|
+
f'<title>{html.escape(title)}</title><style>'
|
|
995
|
+
':root{color-scheme:light dark;font:16px/1.45 system-ui,sans-serif}'
|
|
996
|
+
'body{margin:0 auto;max-width:40rem;padding:1rem}ul{list-style:none;padding:0}'
|
|
997
|
+
'li{border:1px solid #8885;border-radius:.5rem;margin:.75rem 0;padding:.25rem .75rem .75rem}'
|
|
998
|
+
'code{font-size:.85em;word-break:break-all}button{font:inherit;padding:.4rem 1rem;margin-right:.5rem}'
|
|
999
|
+
'.who{opacity:.7}.note{border-left:3px solid #888;padding-left:.5rem}'
|
|
1000
|
+
f'</style></head><body><h1>{html.escape(title)}</h1>{content}</body></html>').encode('utf-8')
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
# -------------------------------------------------------------------------------------------------------- HTTP
|
|
1004
|
+
|
|
1005
|
+
class _Reply(Exception):
|
|
1006
|
+
def __init__(self, status, error):
|
|
1007
|
+
super().__init__(error)
|
|
1008
|
+
self.status, self.error = status, error
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
class Handler(BaseHTTPRequestHandler):
|
|
1012
|
+
server_version = 'lanes-enrold'
|
|
1013
|
+
timeout = 15
|
|
1014
|
+
ROUTES = {'/site': ('GET',), '/redeem': ('POST',), '/request': ('POST',), '/pending': ('GET', 'POST')}
|
|
1015
|
+
|
|
1016
|
+
def version_string(self):
|
|
1017
|
+
return self.server_version
|
|
1018
|
+
|
|
1019
|
+
def log_message(self, format, *args): # no access log: enrol.log is the record, the journal the errors
|
|
1020
|
+
pass
|
|
1021
|
+
|
|
1022
|
+
def do_GET(self):
|
|
1023
|
+
self._route('GET')
|
|
1024
|
+
|
|
1025
|
+
def do_POST(self):
|
|
1026
|
+
self._route('POST')
|
|
1027
|
+
|
|
1028
|
+
# ------------------------------------------------------------------------------------------- plumbing
|
|
1029
|
+
|
|
1030
|
+
def _send(self, status, body, content_type, headers=()):
|
|
1031
|
+
if self.command == 'POST' and not self._body_read:
|
|
1032
|
+
self._discard() # an unread body would reset the proxy's connection
|
|
1033
|
+
self.send_response(status)
|
|
1034
|
+
self.send_header('Content-Type', content_type)
|
|
1035
|
+
self.send_header('Content-Length', str(len(body)))
|
|
1036
|
+
self.send_header('Cache-Control', 'no-store')
|
|
1037
|
+
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
1038
|
+
for name, value in headers:
|
|
1039
|
+
self.send_header(name, value)
|
|
1040
|
+
self.end_headers()
|
|
1041
|
+
self.wfile.write(body)
|
|
1042
|
+
|
|
1043
|
+
def _json(self, status, payload, headers=()):
|
|
1044
|
+
self._send(status, json.dumps(payload).encode('utf-8'), 'application/json; charset=utf-8', headers)
|
|
1045
|
+
|
|
1046
|
+
def _html(self, status, body):
|
|
1047
|
+
self._send(status, body, 'text/html; charset=utf-8',
|
|
1048
|
+
(('Content-Security-Policy', HTML_POLICY), ('X-Frame-Options', 'DENY'),
|
|
1049
|
+
('Referrer-Policy', 'no-referrer')))
|
|
1050
|
+
|
|
1051
|
+
def _discard(self):
|
|
1052
|
+
self._body_read = True
|
|
1053
|
+
try:
|
|
1054
|
+
left = min(max(int(self.headers.get('Content-Length') or 0), 0), DISCARD_LIMIT)
|
|
1055
|
+
except ValueError:
|
|
1056
|
+
left = 0
|
|
1057
|
+
try:
|
|
1058
|
+
while left > 0:
|
|
1059
|
+
chunk = self.rfile.read(min(left, 65536))
|
|
1060
|
+
if not chunk:
|
|
1061
|
+
break
|
|
1062
|
+
left -= len(chunk)
|
|
1063
|
+
except OSError:
|
|
1064
|
+
pass
|
|
1065
|
+
self.close_connection = True
|
|
1066
|
+
|
|
1067
|
+
def _body(self):
|
|
1068
|
+
if self.headers.get('Transfer-Encoding') is not None:
|
|
1069
|
+
self._body_read = True # chunked bodies are not read: answer and close
|
|
1070
|
+
raise _Reply(411, 'length-required')
|
|
1071
|
+
length = self.headers.get('Content-Length')
|
|
1072
|
+
if length is None:
|
|
1073
|
+
raise _Reply(411, 'length-required')
|
|
1074
|
+
try:
|
|
1075
|
+
size = int(length)
|
|
1076
|
+
except ValueError:
|
|
1077
|
+
size = -1
|
|
1078
|
+
if size < 0:
|
|
1079
|
+
self._body_read = True
|
|
1080
|
+
raise _Reply(400, 'bad-request')
|
|
1081
|
+
if size > MAX_BODY:
|
|
1082
|
+
self._discard()
|
|
1083
|
+
raise _Reply(413, 'too-large')
|
|
1084
|
+
data = self.rfile.read(size)
|
|
1085
|
+
self._body_read = True
|
|
1086
|
+
if len(data) != size:
|
|
1087
|
+
raise _Reply(400, 'bad-request')
|
|
1088
|
+
return data
|
|
1089
|
+
|
|
1090
|
+
def _json_body(self):
|
|
1091
|
+
data = self._body()
|
|
1092
|
+
try:
|
|
1093
|
+
document = json.loads(data.decode('utf-8'))
|
|
1094
|
+
except (UnicodeDecodeError, ValueError):
|
|
1095
|
+
raise _Reply(400, 'bad-request') from None
|
|
1096
|
+
if not isinstance(document, dict):
|
|
1097
|
+
raise _Reply(400, 'bad-request')
|
|
1098
|
+
return document
|
|
1099
|
+
|
|
1100
|
+
# --------------------------------------------------------------------------------------------- routes
|
|
1101
|
+
|
|
1102
|
+
def _route(self, method):
|
|
1103
|
+
self._body_read = False
|
|
1104
|
+
service = self.server.service
|
|
1105
|
+
try:
|
|
1106
|
+
caller = caller_of(self.headers)
|
|
1107
|
+
service.observe(caller, through_serve(self.headers.get('X-Forwarded-For'), self.client_address[0]))
|
|
1108
|
+
path = urllib.parse.urlsplit(self.path).path
|
|
1109
|
+
request_id = path[len('/request/'):] if path.startswith('/request/') else None
|
|
1110
|
+
allowed = ('GET',) if request_id is not None else self.ROUTES.get(path)
|
|
1111
|
+
if allowed is None:
|
|
1112
|
+
return self._json(404, {'error': 'not-found'})
|
|
1113
|
+
if method not in allowed:
|
|
1114
|
+
return self._json(405, {'error': 'method-not-allowed'}, (('Allow', ', '.join(allowed)),))
|
|
1115
|
+
if request_id is not None:
|
|
1116
|
+
return self._request_state(service, caller, request_id)
|
|
1117
|
+
if path == '/site':
|
|
1118
|
+
return self._json(200, site_facts(caller, service.request_mode, self._source()))
|
|
1119
|
+
if path == '/redeem':
|
|
1120
|
+
return self._redeem(service, caller)
|
|
1121
|
+
if path == '/request':
|
|
1122
|
+
return self._request(service, caller)
|
|
1123
|
+
return self._pending(service, caller, method)
|
|
1124
|
+
except _Reply as reply:
|
|
1125
|
+
return self._json(reply.status, {'error': reply.error})
|
|
1126
|
+
except Exception: # noqa: BLE001 — one request's bug never stops the service
|
|
1127
|
+
traceback.print_exc(file=sys.stderr)
|
|
1128
|
+
try:
|
|
1129
|
+
self._json(500, {'error': 'server-error'})
|
|
1130
|
+
except OSError:
|
|
1131
|
+
pass
|
|
1132
|
+
|
|
1133
|
+
def _source(self):
|
|
1134
|
+
"""The rate-limit source of this request (source_key); `/site` echoes it."""
|
|
1135
|
+
return source_key(self.headers.get('X-Forwarded-For'), self.client_address[0])
|
|
1136
|
+
|
|
1137
|
+
def _redeem(self, service, caller):
|
|
1138
|
+
now = service.clock()
|
|
1139
|
+
trusted = caller if service.request_mode else NOBODY
|
|
1140
|
+
source = self._source()
|
|
1141
|
+
token, wait = service.attempt(source)
|
|
1142
|
+
if token is None:
|
|
1143
|
+
audit('redeem', None, None, trusted.login, None, 'rate-limited', now)
|
|
1144
|
+
return self._json(429, {'error': 'rate-limited'}, (('Retry-After', str(wait)),))
|
|
1145
|
+
try:
|
|
1146
|
+
fields = self._json_body()
|
|
1147
|
+
except _Reply as reply:
|
|
1148
|
+
audit('redeem', None, None, trusted.login, None, reply.error, now)
|
|
1149
|
+
raise
|
|
1150
|
+
status, payload = redeem(fields, trusted, now)
|
|
1151
|
+
# A 409 (device-taken | key-taken) is answered only after a valid, live code: its holder is retrying with
|
|
1152
|
+
# another --device, as install.sh says to, not guessing codes, so it is not charged (DEF-1731). Everything
|
|
1153
|
+
# else that is not a 200 — an invalid code, a malformed body, an invalid device — still counts.
|
|
1154
|
+
if status in (200, 409):
|
|
1155
|
+
service.succeeded(source, token)
|
|
1156
|
+
return self._json(status, payload)
|
|
1157
|
+
|
|
1158
|
+
def _request(self, service, caller):
|
|
1159
|
+
fields = self._json_body()
|
|
1160
|
+
refusal = service.gate(caller)
|
|
1161
|
+
if refusal:
|
|
1162
|
+
audit('request', None, None, caller.login, None, refusal, service.clock())
|
|
1163
|
+
return self._json(403, {'error': refusal})
|
|
1164
|
+
status, payload = create_request(fields, caller, service.clock())
|
|
1165
|
+
return self._json(status, payload)
|
|
1166
|
+
|
|
1167
|
+
def _request_state(self, service, caller, request_id):
|
|
1168
|
+
document = read_request(request_id) if service.request_mode and caller.login else None
|
|
1169
|
+
if document is None or document['login'].casefold() != caller.login.casefold():
|
|
1170
|
+
return self._json(404, {'error': 'not-found'})
|
|
1171
|
+
return self._json(200, {'id': document['id'], 'state': request_state(document, service.clock()),
|
|
1172
|
+
'expires_at': document['expires_at']})
|
|
1173
|
+
|
|
1174
|
+
def _pending(self, service, caller, method):
|
|
1175
|
+
form = {}
|
|
1176
|
+
if method == 'POST':
|
|
1177
|
+
try:
|
|
1178
|
+
form = urllib.parse.parse_qs(self._body().decode('utf-8', 'replace'), max_num_fields=8)
|
|
1179
|
+
except ValueError: # more fields than an approve/deny form carries
|
|
1180
|
+
raise _Reply(400, 'bad-request') from None
|
|
1181
|
+
refusal = service.gate(caller)
|
|
1182
|
+
if refusal is None and caller.login.casefold() not in member_logins():
|
|
1183
|
+
refusal = 'not-a-member'
|
|
1184
|
+
if refusal:
|
|
1185
|
+
sentence = {'requests-disabled': 'Requests are off on this server (invite-only), so nobody is signed in '
|
|
1186
|
+
'here.',
|
|
1187
|
+
'no-identity': 'No tailnet identity reached this page: open it over the tailnet.',
|
|
1188
|
+
'domain-not-allowed': 'Your tailnet login is not in a domain this server accepts.',
|
|
1189
|
+
'not-a-member': 'Your tailnet login is not a member of this server, so you cannot approve '
|
|
1190
|
+
'requests. A member record names its owner: on the server, run '
|
|
1191
|
+
f'`lanes keys owner <device> {caller.login}` for a device of yours '
|
|
1192
|
+
'(`lanes keys list` shows them).'}[refusal]
|
|
1193
|
+
return self._html(403, _page('Enrolment requests', f'<p>{html.escape(sentence)}</p>'))
|
|
1194
|
+
now = service.clock()
|
|
1195
|
+
if method == 'GET':
|
|
1196
|
+
return self._html(200, pending_page(service, caller, now))
|
|
1197
|
+
# A decision is made only on the browser's own word that the form came from this page (`same-origin`) or from
|
|
1198
|
+
# the user (`none`). An ABSENT header is refused too (DEF-1730): every browser that reaches /pending through
|
|
1199
|
+
# Serve's HTTPS sends it, and a client that does not (curl, a script) has `lanes enrol approve` instead.
|
|
1200
|
+
fetch_site = self.headers.get('Sec-Fetch-Site')
|
|
1201
|
+
if fetch_site not in ('same-origin', 'none'):
|
|
1202
|
+
reason = ('the form came from another site' if fetch_site else
|
|
1203
|
+
'the browser did not say where the form came from (no Sec-Fetch-Site): use a current browser, '
|
|
1204
|
+
'or on the server `lanes enrol approve <id>`')
|
|
1205
|
+
return self._html(403, _page('Enrolment requests', f'<p>Refused: {html.escape(reason)}.</p>'))
|
|
1206
|
+
one = {key: (form.get(key) or [''])[0] for key in ('id', 'action', 'token')}
|
|
1207
|
+
request_id = one['id'].lower()
|
|
1208
|
+
if (not _REQUEST_ID.fullmatch(request_id) or one['action'] not in ACTIONS
|
|
1209
|
+
or not hmac.compare_digest(one['token'].encode('utf-8', 'replace'),
|
|
1210
|
+
service.token(caller.login, request_id).encode())):
|
|
1211
|
+
audit(one['action'] if one['action'] in ACTIONS else 'decide', None, None, caller.login, None,
|
|
1212
|
+
'bad-form', now)
|
|
1213
|
+
return self._html(403, _page('Enrolment requests', '<p>Refused: that form is stale or was not issued '
|
|
1214
|
+
'here. Reload the page and try again.</p>'))
|
|
1215
|
+
try:
|
|
1216
|
+
document = decide(request_id, one['action'], caller.login, now)
|
|
1217
|
+
except L.LanesError as error:
|
|
1218
|
+
return self._html(409, pending_page(service, caller, now, f'Not done: {error}'))
|
|
1219
|
+
word = 'Approved' if document['state'] == 'approved' else 'Denied'
|
|
1220
|
+
message = f"{word} {document['device']} for {document['login']}."
|
|
1221
|
+
return self._html(200, pending_page(service, caller, now, message))
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
class Server(ThreadingHTTPServer):
|
|
1225
|
+
daemon_threads = True
|
|
1226
|
+
request_queue_size = 32
|
|
1227
|
+
|
|
1228
|
+
def __init__(self, address, service):
|
|
1229
|
+
self.service = service
|
|
1230
|
+
super().__init__(address, Handler)
|
|
1231
|
+
|
|
1232
|
+
def server_bind(self): # HTTPServer's getfqdn() lookup is not needed on loopback
|
|
1233
|
+
socketserver.TCPServer.server_bind(self)
|
|
1234
|
+
self.server_name, self.server_port = self.server_address[:2]
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def main(argv=None):
|
|
1238
|
+
parser = argparse.ArgumentParser(prog='lanes-enrold', description='Lanes enrolment service (loopback only).')
|
|
1239
|
+
parser.add_argument('--port', type=int, default=None, help=f'default $LANES_ENROL_PORT or {DEFAULT_PORT}')
|
|
1240
|
+
args = parser.parse_args(argv)
|
|
1241
|
+
port = args.port
|
|
1242
|
+
if port is None:
|
|
1243
|
+
try:
|
|
1244
|
+
port = int(os.environ.get('LANES_ENROL_PORT') or DEFAULT_PORT)
|
|
1245
|
+
except ValueError:
|
|
1246
|
+
parser.error('LANES_ENROL_PORT is not a port number')
|
|
1247
|
+
if not 0 <= port <= 65535:
|
|
1248
|
+
parser.error(f'{port} is not a port number')
|
|
1249
|
+
service = Service.from_environ(os.environ)
|
|
1250
|
+
server = Server(('127.0.0.1', port), service)
|
|
1251
|
+
print(f'lanes-enrold {version()}: listening on 127.0.0.1:{server.server_address[1]}; request mode '
|
|
1252
|
+
f'{service.mode_source()}; '
|
|
1253
|
+
f'domains {",".join(service.domains)}', file=sys.stderr, flush=True)
|
|
1254
|
+
try:
|
|
1255
|
+
server.serve_forever()
|
|
1256
|
+
except KeyboardInterrupt:
|
|
1257
|
+
pass
|
|
1258
|
+
finally:
|
|
1259
|
+
server.server_close()
|
|
1260
|
+
return 0
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
if __name__ == '__main__':
|
|
1264
|
+
sys.exit(main())
|