@vibelord/lanes 1.2.0 → 1.3.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/package.json +1 -1
- package/payload/lanes/README.md +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +301 -45
- package/payload/lanes/container/lane +42 -1
- package/payload/lanes/container/lane-mcp +65 -6
- package/payload/lanes/container/lane_core.py +178 -0
- package/payload/lanes/docs/ARCHITECTURE.md +192 -20
- package/payload/lanes/docs/DESIGN.md +6 -3
- package/payload/lanes/docs/INSTALL.md +25 -8
- package/payload/lanes/docs/RUNBOOK.md +369 -8
- package/payload/lanes/docs/USERGUIDE.md +201 -25
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +106 -13
- package/payload/lanes/libexec/statusd.py +97 -5
- package/payload/lanes/libexec/tokens.py +31 -9
- package/payload/lanes/libexec/update.py +94 -22
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +4 -3
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
"""lanes uploads — one way in for a file a person uploads (docs cockpit W10 task W5, LNS-106).
|
|
2
|
+
|
|
3
|
+
Both upload paths land the file where the agents have always found it AND record it in the docs store:
|
|
4
|
+
* the SSH droplet `lanes upload` (bin/lanes cmd_upload: a tar stream on stdin from the client's `lanes-upload`, the
|
|
5
|
+
sidebar's upload strip through the upload helper, the watch folder, the macOS droplet) — receive_tar, source `ssh`;
|
|
6
|
+
* the Docs page's `POST /up` (libexec/docsd.py: a multipart form from the phone or laptop) — receive, source `web`.
|
|
7
|
+
|
|
8
|
+
Landing, as cmd_upload always did it: `<folder>/uploads/<name>` in the workspace's container, reached through the
|
|
9
|
+
container's home volume (lanes_lib.host_path), where `<folder>` is the workspace's first lane folder, else its working
|
|
10
|
+
directory (landing). An existing name is never overwritten: the next is `<stem>-1<suffix>`, `-2` … (link_free, the
|
|
11
|
+
rule docs_store names documents by). `uploads/` is created on first use (0755) and, when the folder is a git
|
|
12
|
+
repository (`.git` a directory), `.git/info/exclude` gains the `uploads/` line unless a line already names it, so an
|
|
13
|
+
upload is never committed (ensure_excluded). A file appears under its name only once it is complete (written as
|
|
14
|
+
`.incoming-…`, then linked or renamed into place).
|
|
15
|
+
|
|
16
|
+
Safety (DEF-1890, DEF-1891). The folder and everything in it are the container's to change, and the host writes there
|
|
17
|
+
as the account the agents run as (bin/lanes uid_split: the same uid). So every directory is opened component by
|
|
18
|
+
component without following a link (lanes_lib.open_real_dir: an absolute link would resolve against the HOST's root),
|
|
19
|
+
every file is created O_EXCL|O_NOFOLLOW, a tar stream is unpacked here member by member (unpack; never
|
|
20
|
+
tarfile.extractall, which follows a link an agent plants while it runs), and no git process runs in the repository:
|
|
21
|
+
`git check-ignore` runs the repository's `core.fsmonitor`, a command the container chose, on the host.
|
|
22
|
+
|
|
23
|
+
Recording. Each file's bytes pass through a Tee: every chunk read from the upload is written to the landing file and
|
|
24
|
+
handed to docs_store.intake in the same pass, so the store's sha256 receipt is of exactly the bytes that arrived — never
|
|
25
|
+
of a file an agent could rewrite before it was read. The store document is a COPY, never a hard link to the landed
|
|
26
|
+
file: a link shares one inode with a file the container may write, and an edit there would change the receipt-bound
|
|
27
|
+
bytes under an unchanged receipt (test_uploads.Ownership measures that, and that a link cannot even cross to another
|
|
28
|
+
filesystem). Store facts (record): `kind` by extension (kind_for: md, html, svg, else file), `for` and `by` the
|
|
29
|
+
uploader's login, at most UPLOAD_LIMIT bytes a file — a larger file lands without a store document (intake logs
|
|
30
|
+
`too-large`). An SSH upload from a device that no member record owns lands as before, with a docs.log
|
|
31
|
+
`upload - - <lane> <bytes> <sha256> no-owner` line and no store document. LANES_DOCS_AGE_RECIPIENT encrypts the store
|
|
32
|
+
copy only (docs_store), never the landed file.
|
|
33
|
+
"""
|
|
34
|
+
import hashlib
|
|
35
|
+
import os
|
|
36
|
+
import secrets
|
|
37
|
+
import stat
|
|
38
|
+
import tarfile
|
|
39
|
+
from pathlib import PurePosixPath
|
|
40
|
+
|
|
41
|
+
import lanes_lib as L
|
|
42
|
+
import docs_store as D
|
|
43
|
+
|
|
44
|
+
UPLOAD_LIMIT = 32 * 1024 * 1024 # bytes of ONE file the store keeps from an upload (docsd's POST /up body cap)
|
|
45
|
+
MAX_RECORDS = 64 # store documents one SSH upload makes at most (docsd's MAX_PARTS for a form)
|
|
46
|
+
MAX_EXCLUDE = 64 * 1024 # a .git/info/exclude larger than this is not read (nor appended to)
|
|
47
|
+
MAX_LINKS = 4096 # names link_free tries before it gives up
|
|
48
|
+
CHUNK = 64 * 1024
|
|
49
|
+
UPLOADS = 'uploads'
|
|
50
|
+
FILE_MODE, DIR_MODE = 0o644, 0o755 # what a web upload and a new uploads/ get, whatever the caller's umask
|
|
51
|
+
EXCLUDE_BLOCK = b'\n# lanes upload: local intake folder, never committed\nuploads/\n' # cmd_upload's, byte for byte
|
|
52
|
+
EXCLUDES = frozenset(('uploads', 'uploads/', '/uploads', '/uploads/', 'uploads/*', '/uploads/*', 'uploads/**',
|
|
53
|
+
'/uploads/**'))
|
|
54
|
+
KINDS = {'.md': 'md', '.markdown': 'md', '.html': 'html', '.htm': 'html', '.svg': 'svg'}
|
|
55
|
+
_CLOEXEC = getattr(os, 'O_CLOEXEC', 0)
|
|
56
|
+
_DIR = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | _CLOEXEC
|
|
57
|
+
_NEW = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | _CLOEXEC
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UploadError(L.LanesError):
|
|
61
|
+
"""A file that could not be stored (both-or-neither, `receive`): `word` is the refusal (busy, too-large,
|
|
62
|
+
encrypt-failed, invalid, no-owner)."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, word):
|
|
65
|
+
super().__init__(f'the upload could not be stored ({word})')
|
|
66
|
+
self.word = word
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def kind_for(name):
|
|
70
|
+
"""The store kind of an uploaded file: md, html or svg by its extension (case-insensitive), else file."""
|
|
71
|
+
return KINDS.get(PurePosixPath(name or '').suffix.lower(), 'file')
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ----------------------------------------------------------------------------------------------------- the landing
|
|
75
|
+
|
|
76
|
+
def landing(registry, workspace):
|
|
77
|
+
"""(folder, home, parts) of `workspace`'s uploads: the container folder they land under (`<folder>/uploads/`) —
|
|
78
|
+
the first lane folder, else the workspace's working directory —, the container's home volume on the host, and the
|
|
79
|
+
folder's components below it. Raises LanesError exactly as lanes_lib.host_path does (an unsafe path, one outside the
|
|
80
|
+
home volume, a container whose home volume is unknown: not running at the last `lanes sync`)."""
|
|
81
|
+
folder = next((lane.get('folder') for lane in workspace.get('lanes') or [] if lane.get('folder')), None) \
|
|
82
|
+
or workspace.get('cwd') or ''
|
|
83
|
+
info = (registry.get('containers') or {}).get(workspace.get('container')) or {}
|
|
84
|
+
L.host_path(info, folder)
|
|
85
|
+
rest = folder[len(L.CONTAINER_HOME) + 1:]
|
|
86
|
+
return folder, info['home'], [part for part in rest.split('/') if part]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _mkdir(dir_fd, name, mode):
|
|
90
|
+
"""mkdir `name` in dir_fd -> True when it made it (an existing entry is left as it is)."""
|
|
91
|
+
try:
|
|
92
|
+
os.mkdir(name, mode, dir_fd=dir_fd)
|
|
93
|
+
return True
|
|
94
|
+
except FileExistsError:
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def open_uploads(home, parts):
|
|
99
|
+
"""fd of <home>/<parts…>/uploads, made (0755) when absent; every component a real directory — a symbolic link
|
|
100
|
+
anywhere is refused, never followed —, and the repository's exclude file kept naming it (ensure_excluded). The
|
|
101
|
+
caller closes it. Raises LanesError."""
|
|
102
|
+
try:
|
|
103
|
+
root = L.open_real_dir(home, parts)
|
|
104
|
+
except (OSError, L.LanesError):
|
|
105
|
+
raise L.LanesError('the workspace folder cannot be opened without following a symbolic link') from None
|
|
106
|
+
try:
|
|
107
|
+
try:
|
|
108
|
+
created = _mkdir(root, UPLOADS, 0o700)
|
|
109
|
+
except OSError as error:
|
|
110
|
+
raise L.LanesError(f'uploads/ cannot be made in the workspace folder: {error.strerror or error}') from None
|
|
111
|
+
try:
|
|
112
|
+
fd = os.open(UPLOADS, _DIR, dir_fd=root)
|
|
113
|
+
except OSError:
|
|
114
|
+
raise L.LanesError('uploads/ in the workspace folder is not a directory (a symbolic link is refused)') \
|
|
115
|
+
from None
|
|
116
|
+
if created:
|
|
117
|
+
os.fchmod(fd, DIR_MODE)
|
|
118
|
+
try:
|
|
119
|
+
ensure_excluded(root)
|
|
120
|
+
except BaseException:
|
|
121
|
+
os.close(fd)
|
|
122
|
+
raise
|
|
123
|
+
return fd
|
|
124
|
+
finally:
|
|
125
|
+
os.close(root)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _ignores_uploads(data):
|
|
129
|
+
return any(line.strip() in EXCLUDES for line in data.decode('utf-8', 'replace').splitlines())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def ensure_excluded(root):
|
|
133
|
+
"""Keep the repository folder `root` (an fd) from committing uploads/ -> True when it appended. Only when `.git` is a
|
|
134
|
+
real directory (absent, a linked worktree's `.git` file or a link: nothing, as before): `.git/info/exclude` (made
|
|
135
|
+
when absent) gains EXCLUDE_BLOCK unless a line of it already names uploads/ (EXCLUDES). No git process runs here
|
|
136
|
+
(DEF-1890). Raises LanesError when the exclude file is no regular file or cannot be written: an upload that could be
|
|
137
|
+
committed is not made."""
|
|
138
|
+
try:
|
|
139
|
+
git = os.open('.git', _DIR, dir_fd=root)
|
|
140
|
+
except OSError:
|
|
141
|
+
return False
|
|
142
|
+
try:
|
|
143
|
+
try:
|
|
144
|
+
_mkdir(git, 'info', 0o755)
|
|
145
|
+
info = os.open('info', _DIR, dir_fd=git)
|
|
146
|
+
except OSError:
|
|
147
|
+
raise L.LanesError('.git/info in the workspace folder is not a directory') from None
|
|
148
|
+
try:
|
|
149
|
+
try:
|
|
150
|
+
fd = os.open('exclude', os.O_RDWR | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK | _CLOEXEC,
|
|
151
|
+
0o644, dir_fd=info)
|
|
152
|
+
except OSError:
|
|
153
|
+
raise L.LanesError('.git/info/exclude cannot be opened without following a link') from None
|
|
154
|
+
try:
|
|
155
|
+
info_stat = os.fstat(fd)
|
|
156
|
+
if not stat.S_ISREG(info_stat.st_mode) or info_stat.st_size > MAX_EXCLUDE:
|
|
157
|
+
raise L.LanesError('.git/info/exclude is not a plain file of at most 64 KiB')
|
|
158
|
+
data, size = [], 0
|
|
159
|
+
while size <= MAX_EXCLUDE:
|
|
160
|
+
chunk = os.read(fd, CHUNK)
|
|
161
|
+
if not chunk:
|
|
162
|
+
break
|
|
163
|
+
data.append(chunk)
|
|
164
|
+
size += len(chunk)
|
|
165
|
+
if _ignores_uploads(b''.join(data)):
|
|
166
|
+
return False
|
|
167
|
+
os.write(fd, EXCLUDE_BLOCK)
|
|
168
|
+
return True
|
|
169
|
+
except OSError as error:
|
|
170
|
+
raise L.LanesError(f'.git/info/exclude cannot be written: {error.strerror or error}') from None
|
|
171
|
+
finally:
|
|
172
|
+
os.close(fd)
|
|
173
|
+
finally:
|
|
174
|
+
os.close(info)
|
|
175
|
+
finally:
|
|
176
|
+
os.close(git)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _next(name, n):
|
|
180
|
+
path = PurePosixPath(name)
|
|
181
|
+
return f'{path.stem}-{n}{path.suffix}'
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _exists(dir_fd, name):
|
|
185
|
+
try:
|
|
186
|
+
os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
|
|
187
|
+
return True
|
|
188
|
+
except FileNotFoundError:
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def free_name(dir_fd, name):
|
|
193
|
+
"""The first name of `name`'s collision sequence (`name`, `<stem>-1<suffix>`, `-2` …) nothing in dir_fd has."""
|
|
194
|
+
final, n = name, 1
|
|
195
|
+
while _exists(dir_fd, final):
|
|
196
|
+
final, n = _next(name, n), n + 1
|
|
197
|
+
if n > MAX_LINKS:
|
|
198
|
+
raise L.LanesError('no free name in uploads/')
|
|
199
|
+
return final
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def link_free(dir_fd, source, name, source_dir=None):
|
|
203
|
+
"""Hard-link `source` (an entry of source_dir, default dir_fd; never followed when it is a link) to the first free
|
|
204
|
+
name of `name`'s collision sequence in dir_fd -> the name used. link(2) never replaces an existing name, so a name
|
|
205
|
+
taken in the meantime moves on to the next one: an earlier upload is never overwritten."""
|
|
206
|
+
final, n = name, 1
|
|
207
|
+
while True:
|
|
208
|
+
try:
|
|
209
|
+
os.link(source, final, src_dir_fd=dir_fd if source_dir is None else source_dir, dst_dir_fd=dir_fd,
|
|
210
|
+
follow_symlinks=False)
|
|
211
|
+
return final
|
|
212
|
+
except FileExistsError:
|
|
213
|
+
final, n = _next(name, n), n + 1
|
|
214
|
+
if n > MAX_LINKS:
|
|
215
|
+
raise L.LanesError('no free name in uploads/') from None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def rename_free(source_dir, source, dir_fd, name):
|
|
219
|
+
"""Rename the directory `source` (in source_dir) to the first free name of `name`'s sequence in dir_fd -> the name
|
|
220
|
+
used. A name some entry has is skipped (rename would replace an empty directory)."""
|
|
221
|
+
final, n = name, 1
|
|
222
|
+
while True:
|
|
223
|
+
if not _exists(dir_fd, final):
|
|
224
|
+
try:
|
|
225
|
+
os.rename(source, final, src_dir_fd=source_dir, dst_dir_fd=dir_fd)
|
|
226
|
+
return final
|
|
227
|
+
except OSError as error:
|
|
228
|
+
if error.errno not in (17, 39): # EEXIST, ENOTEMPTY: taken meanwhile
|
|
229
|
+
raise
|
|
230
|
+
final, n = _next(name, n), n + 1
|
|
231
|
+
if n > MAX_LINKS:
|
|
232
|
+
raise L.LanesError('no free name in uploads/')
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def remove_tree(dir_fd, name):
|
|
236
|
+
"""Remove the entry `name` of dir_fd and, for a directory, everything in it — never through a link."""
|
|
237
|
+
try:
|
|
238
|
+
info = os.stat(name, dir_fd=dir_fd, follow_symlinks=False)
|
|
239
|
+
except FileNotFoundError:
|
|
240
|
+
return
|
|
241
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
242
|
+
os.unlink(name, dir_fd=dir_fd)
|
|
243
|
+
return
|
|
244
|
+
fd = os.open(name, _DIR, dir_fd=dir_fd)
|
|
245
|
+
try:
|
|
246
|
+
for entry in list(os.scandir(fd)):
|
|
247
|
+
remove_tree(fd, entry.name)
|
|
248
|
+
finally:
|
|
249
|
+
os.close(fd)
|
|
250
|
+
os.rmdir(name, dir_fd=dir_fd)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------------------------------------------------- recording
|
|
254
|
+
|
|
255
|
+
class Tee:
|
|
256
|
+
"""A reader over `source` that writes every chunk it hands out to the fd `out` and hashes it on the way."""
|
|
257
|
+
|
|
258
|
+
def __init__(self, source, out):
|
|
259
|
+
self.source, self.out, self.size, self.digest = source, out, 0, hashlib.sha256()
|
|
260
|
+
|
|
261
|
+
def read(self, size=-1):
|
|
262
|
+
chunk = self.source.read(size if size and size > 0 else CHUNK)
|
|
263
|
+
if chunk:
|
|
264
|
+
view = memoryview(chunk)
|
|
265
|
+
while view:
|
|
266
|
+
view = view[os.write(self.out, view):]
|
|
267
|
+
self.digest.update(chunk)
|
|
268
|
+
self.size += len(chunk)
|
|
269
|
+
return chunk
|
|
270
|
+
|
|
271
|
+
def drain(self):
|
|
272
|
+
"""Read the rest (written to `out` too) -> (bytes, sha256 hex) of everything that passed."""
|
|
273
|
+
while self.read(CHUNK):
|
|
274
|
+
pass
|
|
275
|
+
return self.size, self.digest.hexdigest()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def record(tee, *, login, ws, lane, name, source):
|
|
279
|
+
"""Hand the bytes `tee` yields to docs_store.intake as a document of `login` in `ws`/`lane` named `name` — kind
|
|
280
|
+
by extension, for and by the login, at most UPLOAD_LIMIT bytes — then drain `tee`, so the landed file is complete
|
|
281
|
+
whatever the store said -> (meta or None, problem word or None): too-large, busy, encrypt-failed, invalid (intake
|
|
282
|
+
logged each), no-owner (no login: a docs.log `upload` line with the bytes and sha256 only), no-lane."""
|
|
283
|
+
meta, problem = None, None
|
|
284
|
+
if login is None or lane is None:
|
|
285
|
+
problem = 'no-owner' if login is None else 'no-lane'
|
|
286
|
+
else:
|
|
287
|
+
try:
|
|
288
|
+
meta = D.intake(login, ws, lane, name, tee, limit=UPLOAD_LIMIT, source=source, kind=kind_for(name),
|
|
289
|
+
for_logins=[login], by=login)
|
|
290
|
+
except D.TooLarge:
|
|
291
|
+
problem = 'too-large'
|
|
292
|
+
except D.Busy:
|
|
293
|
+
problem = 'busy'
|
|
294
|
+
except D.EncryptError:
|
|
295
|
+
problem = 'encrypt-failed'
|
|
296
|
+
except D.DocsError:
|
|
297
|
+
problem = 'invalid'
|
|
298
|
+
size, sha256 = tee.drain()
|
|
299
|
+
if meta is None and problem in ('no-owner', 'no-lane'):
|
|
300
|
+
D.audit('upload', login, None, lane, size, sha256, problem)
|
|
301
|
+
return meta, problem
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def receive(uploads, name, reader, *, login, ws, lane, source):
|
|
305
|
+
"""One file into the uploads/ directory `uploads` (an fd) AND the store, both or neither (POST /up) -> (meta, the
|
|
306
|
+
name it landed under). Written as `.incoming-<hex>` while the store takes the same bytes, then linked to `name`'s
|
|
307
|
+
first free name. Raises UploadError(word) when the store refused it (nothing lands), OSError when it could not land
|
|
308
|
+
(the store document is removed again)."""
|
|
309
|
+
temporary = f'.incoming-{secrets.token_hex(8)}'
|
|
310
|
+
fd = os.open(temporary, _NEW, 0o600, dir_fd=uploads)
|
|
311
|
+
try:
|
|
312
|
+
meta, problem = record(Tee(reader, fd), login=login, ws=ws, lane=lane, name=name, source=source)
|
|
313
|
+
if meta is None:
|
|
314
|
+
raise UploadError(problem)
|
|
315
|
+
try:
|
|
316
|
+
os.fchmod(fd, FILE_MODE)
|
|
317
|
+
final = link_free(uploads, temporary, name)
|
|
318
|
+
except BaseException:
|
|
319
|
+
try:
|
|
320
|
+
D.remove(meta['id'], login)
|
|
321
|
+
except D.DocsError:
|
|
322
|
+
pass
|
|
323
|
+
raise
|
|
324
|
+
return meta, final
|
|
325
|
+
finally:
|
|
326
|
+
os.close(fd)
|
|
327
|
+
try:
|
|
328
|
+
os.unlink(temporary, dir_fd=uploads)
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --------------------------------------------------------------------------------------------- the SSH tar stream
|
|
334
|
+
|
|
335
|
+
def member_parts(name):
|
|
336
|
+
"""The path components of a tar member name below the upload: leading `/` and `.` components dropped (as tar's
|
|
337
|
+
data filter strips a leading slash), None for a name with `..` or a NUL (refused)."""
|
|
338
|
+
if not isinstance(name, str) or '\0' in name:
|
|
339
|
+
return None
|
|
340
|
+
parts = [part for part in name.split('/') if part not in ('', '.')]
|
|
341
|
+
return None if '..' in parts else parts
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _file_mode(mode):
|
|
345
|
+
return (mode & 0o755) | 0o600 # tar's data filter: no setuid/setgid/sticky, no group/other write, owner rw
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _dir_mode(mode):
|
|
349
|
+
return (mode & 0o755) | 0o700
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _dirs(base, parts, create=True):
|
|
353
|
+
"""fd of base/<parts…>, each a real directory (made 0755 when absent and `create`); never through a link."""
|
|
354
|
+
fd = os.dup(base)
|
|
355
|
+
try:
|
|
356
|
+
for part in parts:
|
|
357
|
+
if create and _mkdir(fd, part, 0o700):
|
|
358
|
+
made = os.open(part, _DIR, dir_fd=fd)
|
|
359
|
+
os.fchmod(made, DIR_MODE)
|
|
360
|
+
os.close(fd)
|
|
361
|
+
fd = made
|
|
362
|
+
continue
|
|
363
|
+
child = os.open(part, _DIR, dir_fd=fd)
|
|
364
|
+
os.close(fd)
|
|
365
|
+
fd = child
|
|
366
|
+
except BaseException:
|
|
367
|
+
os.close(fd)
|
|
368
|
+
raise
|
|
369
|
+
return fd
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _create(parent, name, make):
|
|
373
|
+
"""make(parent, name) for a new entry; an entry of that name an earlier member made is replaced (a tar stream may
|
|
374
|
+
carry a path twice, and extraction keeps the last)."""
|
|
375
|
+
try:
|
|
376
|
+
return make(parent, name)
|
|
377
|
+
except FileExistsError:
|
|
378
|
+
os.unlink(name, dir_fd=parent)
|
|
379
|
+
return make(parent, name)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _link_inside(parts, target):
|
|
383
|
+
"""Whether a symbolic link at `parts` pointing at `target` stays inside the upload (relative, never above it)."""
|
|
384
|
+
if not target or target.startswith('/') or '\0' in target:
|
|
385
|
+
return False
|
|
386
|
+
depth = len(parts) - 1
|
|
387
|
+
for part in target.split('/'):
|
|
388
|
+
if part == '..':
|
|
389
|
+
depth -= 1
|
|
390
|
+
if depth < 0:
|
|
391
|
+
return False
|
|
392
|
+
elif part not in ('', '.'):
|
|
393
|
+
depth += 1
|
|
394
|
+
return True
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def unpack(stream, staging, on_file):
|
|
398
|
+
"""Unpack the tar stream `stream` into the directory `staging` (an fd) member by member -> the top-level names in
|
|
399
|
+
the order they came. Directories and regular files are made without following a link; a regular file's bytes go
|
|
400
|
+
through a Tee into it, and on_file(parts, tee) is called while they stream (it must drain the tee); a symbolic
|
|
401
|
+
link is kept only when it stays inside the upload and a hard link only to a file of it; a device, FIFO or any other
|
|
402
|
+
member is refused (LanesError), as tar's data filter refuses it. An empty stream is no member."""
|
|
403
|
+
tops = []
|
|
404
|
+
try:
|
|
405
|
+
archive = tarfile.open(fileobj=stream, mode='r|')
|
|
406
|
+
except tarfile.ReadError as error:
|
|
407
|
+
if 'empty' in str(error):
|
|
408
|
+
return tops
|
|
409
|
+
raise L.LanesError('the upload is not a tar stream') from None
|
|
410
|
+
with archive:
|
|
411
|
+
try:
|
|
412
|
+
for member in archive:
|
|
413
|
+
parts = member_parts(member.name)
|
|
414
|
+
if parts is None:
|
|
415
|
+
raise L.LanesError('refused: an archive member climbs out of the upload')
|
|
416
|
+
if not parts:
|
|
417
|
+
continue
|
|
418
|
+
if parts[0] not in tops:
|
|
419
|
+
tops.append(parts[0])
|
|
420
|
+
if member.isdir():
|
|
421
|
+
fd = _dirs(staging, parts)
|
|
422
|
+
os.fchmod(fd, _dir_mode(member.mode))
|
|
423
|
+
os.close(fd)
|
|
424
|
+
continue
|
|
425
|
+
parent = _dirs(staging, parts[:-1])
|
|
426
|
+
try:
|
|
427
|
+
if member.isreg():
|
|
428
|
+
fd = _create(parent, parts[-1], lambda p, n: os.open(n, _NEW, 0o600, dir_fd=p))
|
|
429
|
+
try:
|
|
430
|
+
on_file(parts, Tee(archive.extractfile(member), fd))
|
|
431
|
+
os.fchmod(fd, _file_mode(member.mode))
|
|
432
|
+
os.utime(fd, (member.mtime, member.mtime))
|
|
433
|
+
finally:
|
|
434
|
+
os.close(fd)
|
|
435
|
+
elif member.issym():
|
|
436
|
+
if not _link_inside(parts, member.linkname):
|
|
437
|
+
raise L.LanesError('refused: an archive link points outside the upload')
|
|
438
|
+
_create(parent, parts[-1], lambda p, n: os.symlink(member.linkname, n, dir_fd=p))
|
|
439
|
+
elif member.islnk():
|
|
440
|
+
target = member_parts(member.linkname)
|
|
441
|
+
if not target:
|
|
442
|
+
raise L.LanesError('refused: an archive hard link points outside the upload')
|
|
443
|
+
source = _dirs(staging, target[:-1], create=False)
|
|
444
|
+
try:
|
|
445
|
+
_create(parent, parts[-1], lambda p, n: os.link(target[-1], n, src_dir_fd=source,
|
|
446
|
+
dst_dir_fd=p, follow_symlinks=False))
|
|
447
|
+
finally:
|
|
448
|
+
os.close(source)
|
|
449
|
+
else:
|
|
450
|
+
raise L.LanesError('refused: the archive holds a device, a FIFO or another special file')
|
|
451
|
+
finally:
|
|
452
|
+
os.close(parent)
|
|
453
|
+
except (tarfile.TarError, EOFError):
|
|
454
|
+
raise L.LanesError('the upload is not a whole tar stream') from None
|
|
455
|
+
except OSError as error:
|
|
456
|
+
raise L.LanesError(f'the upload could not be unpacked: {error.strerror or error}') from None
|
|
457
|
+
return tops
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def place(staging, name, uploads):
|
|
461
|
+
"""Move the top-level entry `name` of `staging` into `uploads` under its first free name -> that name: a file (or
|
|
462
|
+
a link) by link(2) — the same inode, no copy —, a directory by rename. Both directories are on the volume."""
|
|
463
|
+
info = os.stat(name, dir_fd=staging, follow_symlinks=False)
|
|
464
|
+
if stat.S_ISDIR(info.st_mode):
|
|
465
|
+
return rename_free(staging, name, uploads, name)
|
|
466
|
+
final = link_free(uploads, name, name, source_dir=staging)
|
|
467
|
+
os.unlink(name, dir_fd=staging)
|
|
468
|
+
return final
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def upload_lane(workspace, device=None):
|
|
472
|
+
"""The lane an SSH upload to `workspace` is recorded under: the lane `device`'s first viewport shows on this board
|
|
473
|
+
(views/<device>/<ws>.main.target, the lane `--paste` types into), else the board's default lane (defaults/<ws>),
|
|
474
|
+
else its first lane; None for a board without lanes."""
|
|
475
|
+
ids = [lane['id'] for lane in workspace.get('lanes') or [] if isinstance(lane, dict)
|
|
476
|
+
and isinstance(lane.get('id'), str)]
|
|
477
|
+
paths = [L.STATE / 'views' / device / f"{workspace['id']}.main.target"] if device else []
|
|
478
|
+
for path in paths + [L.STATE / 'defaults' / workspace['id']]:
|
|
479
|
+
try:
|
|
480
|
+
first = path.read_text().split('\n')[0].strip()
|
|
481
|
+
except (OSError, UnicodeDecodeError):
|
|
482
|
+
continue
|
|
483
|
+
if first in ids:
|
|
484
|
+
return first
|
|
485
|
+
return ids[0] if ids else None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def receive_tar(stream, uploads, *, login, ws, lane, say=None):
|
|
489
|
+
"""An SSH upload: the tar stream `stream` into the uploads/ directory `uploads` (an fd) and the store -> the names
|
|
490
|
+
that landed in uploads/, sorted (cmd_upload's order). Unpacked into a fresh `.incoming-<pid>-<hex>` there, each
|
|
491
|
+
regular file recorded while it streams — a top-level file under the name it is about to land as, a file of a
|
|
492
|
+
folder under its own name; at most MAX_RECORDS documents, the rest land with a docs.log `too-many` line —, then
|
|
493
|
+
every top-level entry placed. A stream that fails part-way lands nothing, as before, and the documents it had
|
|
494
|
+
already recorded are removed again. say(text) hears one line per file (its receipt, or why the store has none)."""
|
|
495
|
+
staging = f'.incoming-{os.getpid()}-{secrets.token_hex(4)}'
|
|
496
|
+
try:
|
|
497
|
+
os.mkdir(staging, 0o700, dir_fd=uploads)
|
|
498
|
+
except OSError as error:
|
|
499
|
+
raise L.LanesError(f'uploads/ cannot be written: {error.strerror or error}') from None
|
|
500
|
+
landed, recorded = [], []
|
|
501
|
+
try:
|
|
502
|
+
staging_fd = os.open(staging, _DIR, dir_fd=uploads)
|
|
503
|
+
try:
|
|
504
|
+
def on_file(parts, tee):
|
|
505
|
+
name = free_name(uploads, parts[0]) if len(parts) == 1 else parts[-1]
|
|
506
|
+
if len(recorded) >= MAX_RECORDS:
|
|
507
|
+
size, sha256 = tee.drain()
|
|
508
|
+
D.audit('upload', login, None, lane, size, sha256, 'too-many')
|
|
509
|
+
problem, meta = 'too-many', None
|
|
510
|
+
else:
|
|
511
|
+
meta, problem = record(tee, login=login, ws=ws, lane=lane, name=name, source='ssh')
|
|
512
|
+
if meta is not None:
|
|
513
|
+
recorded.append(meta)
|
|
514
|
+
if say:
|
|
515
|
+
say(f'# docs: {meta["name"]} {meta["bytes"]} B sha256 {meta["sha256"]} {meta["id"]}' if meta
|
|
516
|
+
else f'# docs: {name}: no receipt ({problem})')
|
|
517
|
+
try:
|
|
518
|
+
tops = unpack(stream, staging_fd, on_file)
|
|
519
|
+
except BaseException:
|
|
520
|
+
for meta in recorded:
|
|
521
|
+
try:
|
|
522
|
+
D.remove(meta['id'], login)
|
|
523
|
+
except D.DocsError:
|
|
524
|
+
pass
|
|
525
|
+
raise
|
|
526
|
+
for name in sorted(tops):
|
|
527
|
+
try:
|
|
528
|
+
landed.append(place(staging_fd, name, uploads))
|
|
529
|
+
except OSError as error:
|
|
530
|
+
raise L.LanesError(f'{name} could not be moved into uploads/: {error.strerror or error}') from None
|
|
531
|
+
finally:
|
|
532
|
+
os.close(staging_fd)
|
|
533
|
+
finally:
|
|
534
|
+
try:
|
|
535
|
+
remove_tree(uploads, staging)
|
|
536
|
+
except OSError:
|
|
537
|
+
pass
|
|
538
|
+
return landed
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=lanes docs service (login-bound docs, artifacts and uploads) on loopback 127.0.0.1:8185
|
|
3
|
+
|
|
4
|
+
# Enabled and restarted by every server install (install/host-install.sh). lanes-mobile's Caddy forwards
|
|
5
|
+
# https://<host>:8443/lanes-docs/* here (web/Caddyfile `handle_path /lanes-docs/*`). It reads and writes the docs store
|
|
6
|
+
# ~/.local/state/lanes/docs/ and docs.log and reads members/, enrol-mode and registry.json; its publish pick-up (W4)
|
|
7
|
+
# reads each workspace container's ~/.lz/state/publish/ through /proc/<pid>/root, as lanes-statusd does, and writes the
|
|
8
|
+
# receipts back there; and (W5) it lands uploads in a workspace's uploads/ through the container's home volume. Hence
|
|
9
|
+
# it has no ProtectHome.
|
|
10
|
+
# It answers nothing until the operator pins the Serve header check (`lanes enrol mode request`, read on every
|
|
11
|
+
# request). Optional settings (LANES_DOCS_DOMAINS, LANES_DOCS_PORT, and the default-off encryption at rest
|
|
12
|
+
# LANES_DOCS_AGE_RECIPIENT / LANES_DOCS_AGE_IDENTITY, which `lanes upload` reads from the same drop-in) go in a drop-in,
|
|
13
|
+
# `systemctl --user edit lanes-docsd.service`, which a re-install keeps (RUNBOOK "Docs service").
|
|
14
|
+
# Memory: docsd holds at most two 32 MiB buffers at once (UPLOAD_SLOTS, shared by uploads and decryptions, one per
|
|
15
|
+
# login), two Markdown renders and an 8 MiB render cache, and its publish pick-up reads at most 4,096 directory entries
|
|
16
|
+
# of a container per cycle, so what a container plants or a member sends stays under MemoryMax (RUNBOOK "Docs service",
|
|
17
|
+
# Limits; the composed peak is not measured: DEF-1917).
|
|
18
|
+
[Service]
|
|
19
|
+
ExecStart=/usr/bin/python3 -S -E "@REPO@/libexec/docsd.py"
|
|
20
|
+
Restart=on-failure
|
|
21
|
+
RestartSec=10
|
|
22
|
+
NoNewPrivileges=true
|
|
23
|
+
UMask=0077
|
|
24
|
+
MemoryHigh=128M
|
|
25
|
+
MemoryMax=192M
|
|
26
|
+
Slice=lanes.slice
|
|
27
|
+
|
|
28
|
+
[Install]
|
|
29
|
+
WantedBy=default.target
|
|
@@ -8,12 +8,35 @@
|
|
|
8
8
|
http://:8083 {
|
|
9
9
|
bind 127.0.0.1
|
|
10
10
|
root * "{$LANES_REPO}/web"
|
|
11
|
-
|
|
11
|
+
# lanes-docsd answers /lanes-docs/* with its own security headers per route (an agent's HTML or SVG artifact is a
|
|
12
|
+
# sandboxed top-level document), so the site-wide set below skips that prefix: docsd's are the only ones it sends.
|
|
13
|
+
@notdocs not path /lanes-docs/*
|
|
14
|
+
header @notdocs {
|
|
12
15
|
X-Content-Type-Options nosniff
|
|
13
16
|
X-Frame-Options DENY
|
|
14
17
|
Referrer-Policy strict-origin-when-cross-origin
|
|
15
18
|
Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:; img-src 'self' data:"
|
|
16
19
|
}
|
|
20
|
+
# lanes-docsd.service (libexec/docsd.py): the docs cockpit, login-bound pages, downloads, artifacts and uploads.
|
|
21
|
+
# handle_path strips /lanes-docs. It stays the FIRST handle of the site: Caddy never reaches a block declared after
|
|
22
|
+
# a handle with a named matcher (the Zellij routes below).
|
|
23
|
+
handle_path /lanes-docs/* {
|
|
24
|
+
reverse_proxy 127.0.0.1:8185 {
|
|
25
|
+
# As on /enrol/*: keep the X-Forwarded-For Tailscale Serve sets (the client's tailnet address): docsd
|
|
26
|
+
# answers only a request that carries one, and only with Serve's identity headers. Caddy >= 2.5.
|
|
27
|
+
trusted_proxies 127.0.0.0/8
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
# The docs page's pinned Mermaid (plan W10 W6, LNS-107): install/enable-docs.sh puts holders.lock's pin into
|
|
31
|
+
# $LANES_SHARE/vendor (services.py sets LANES_SHARE in lanes-mobile.service). Immutable: a page names a VERSIONED
|
|
32
|
+
# file, so a new pin is a new URL (DEF-1870). Caddy sorts a single-path route ahead of /lanes-assets/* only past
|
|
33
|
+
# routes that also have a single path (Caddy issue #5037; DEF-1872): keep this block with the single-path routes
|
|
34
|
+
# above the named-matcher ones (/lanes-docs/* does not overlap it, so their order between themselves is free).
|
|
35
|
+
handle_path /lanes-assets/vendor/* {
|
|
36
|
+
root * "{$LANES_SHARE}/vendor"
|
|
37
|
+
header Cache-Control "public, max-age=31536000, immutable"
|
|
38
|
+
file_server
|
|
39
|
+
}
|
|
17
40
|
handle_path /lanes-assets/* {
|
|
18
41
|
header Cache-Control "no-cache"
|
|
19
42
|
file_server
|