CH-J Server Managerserver management over SSH
Menu
Published source

CH-J Server Manager

Browse directories and files for a specific application release.

Download source ZIP
CH-J Proprietary Software License 1.14

Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.

12,3 KB · 327 linesDownload file
1"""Fixed Linux editor protocol. Invoked in memory; never installed on the server.
3All path walks use directory descriptors and O_NOFOLLOW. The working copy is
4retained even after replacement: only a subsequent confirmed cleanup removes it.
5"""
6import base64
7import errno
8import fcntl
9import hashlib
10import json
11import os
12import pwd
13import re
14import secrets
15import stat
16import sys
18LIMIT = 25 * 1024 * 1024
19REPLACED = False
22def fail(code):
23 raise ValueError(code)
26def directory(path, privileged=False):
27 if not path.startswith('/') or '\x00' in path or any(p in ('.', '..') for p in path.split('/')):
28 fail('FILE_UNSAFE_PATH')
29 fd = os.open('/', os.O_RDONLY | os.O_DIRECTORY)
30 try:
31 def trusted(value):
32 s = os.fstat(value)
33 if privileged and (s.st_uid != 0 or s.st_mode & 0o022 or 'system.posix_acl_access' in os.listxattr(value)):
34 fail('FILE_UNSAFE_PRIVILEGED_DIRECTORY')
35 trusted(fd)
36 for part in filter(None, path.split('/')):
37 nxt = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
38 os.close(fd)
39 fd = nxt
40 trusted(fd)
41 return fd
42 except BaseException:
43 os.close(fd)
44 raise
47def regular(parent, name, owner=None, private=False):
48 fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
49 s = os.fstat(fd)
50 if not stat.S_ISREG(s.st_mode) or s.st_nlink != 1 or (owner is not None and s.st_uid != owner) or (private and stat.S_IMODE(s.st_mode) != 0o600):
51 os.close(fd)
52 fail('FILE_UNSAFE_TYPE_OR_LINK')
53 return fd
56def attributes(fd):
57 # Linux ACLs, capabilities and SELinux labels are extended attributes too.
58 # Fail closed if any attribute cannot be read or reproduced.
59 return {k: base64.b64encode(os.getxattr(fd, k)).decode('ascii') for k in sorted(os.listxattr(fd))}
62def snapshot(fd):
63 before = os.fstat(fd)
64 if before.st_size > LIMIT:
65 fail('FILE_TOO_LARGE')
66 os.lseek(fd, 0, os.SEEK_SET)
67 pieces = []
68 length = 0
69 while True:
70 piece = os.read(fd, min(65536, LIMIT + 1 - length))
71 if not piece:
72 break
73 pieces.append(piece)
74 length += len(piece)
75 if length > LIMIT:
76 fail('FILE_TOO_LARGE')
77 data = b''.join(pieces)
78 xattrs = attributes(fd)
79 after = os.fstat(fd)
80 if (before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns):
81 fail('FILE_CONFLICT')
82 meta = dict(dev=after.st_dev, ino=after.st_ino, uid=after.st_uid, gid=after.st_gid,
83 mode=stat.S_IMODE(after.st_mode), size=after.st_size,
84 mtimeNs=str(after.st_mtime_ns), ctimeNs=str(after.st_ctime_ns), xattrs=xattrs,
85 hash=hashlib.sha256(data).hexdigest())
86 return data, meta
89def target(path, privileged=False):
90 parent, name = os.path.split(path)
91 if not name or name in ('.', '..'):
92 fail('FILE_UNSAFE_PATH')
93 d = directory(parent, privileged)
94 try:
95 return d, name, regular(d, name)
96 except BaseException:
97 os.close(d)
98 raise
101def workspace(uid=None):
102 uid = os.getuid() if uid is None else uid
103 home = pwd.getpwuid(uid).pw_dir
104 h = directory(home)
105 try:
106 hs = os.fstat(h)
107 if hs.st_uid != uid or hs.st_mode & 0o022 or 'system.posix_acl_access' in os.listxattr(h):
108 fail('FILE_UNSAFE_WORKSPACE')
109 try:
110 os.mkdir('ch-j-sm', 0o700, dir_fd=h)
111 except FileExistsError:
112 pass
113 d = os.open('ch-j-sm', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=h)
114 s = os.fstat(d)
115 if s.st_uid != uid:
116 os.close(d)
117 fail('FILE_UNSAFE_WORKSPACE')
118 os.fchmod(d, 0o700)
119 return d, os.path.join(home, 'ch-j-sm'), uid
120 finally:
121 os.close(h)
124def operation_id(value):
125 if not re.fullmatch('[a-f0-9]{32}', str(value)):
126 fail('FILE_INVALID_RECOVERY_ID')
127 return value
130def names(value):
131 value = operation_id(value)
132 return '.' + value + '.tmp', '.' + value + '.json'
135def put_record(d, name, value):
136 data = json.dumps(value).encode('ascii')
137 if len(data) > 256 * 1024:
138 fail('FILE_METADATA_UNSUPPORTED')
139 fd = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=d)
140 with os.fdopen(fd, 'wb') as out:
141 os.fchmod(out.fileno(), 0o600)
142 out.write(data)
143 out.flush()
144 os.fsync(out.fileno())
145 os.fsync(d)
148def get_record(d, name, uid):
149 fd = regular(d, name, uid, True)
150 with os.fdopen(fd, 'rb') as inp:
151 raw = inp.read(256 * 1024 + 1)
152 if len(raw) > 256 * 1024:
153 fail('FILE_INVALID_RECOVERY_RECORD')
154 record = json.loads(raw)
155 if not re.fullmatch('[a-f0-9]{64}', record['hash']):
156 fail('FILE_INVALID_RECOVERY_RECORD')
157 return record
160def preserved(actual, original):
161 return all(actual[k] == original[k] for k in ('uid', 'gid', 'mode', 'xattrs'))
164def inspect_record(d, uid, value, include_text=False):
165 tmp, journal = names(value)
166 r = get_record(d, journal, uid)
167 fd = regular(d, tmp, uid, True)
168 try:
169 data, staged = snapshot(fd)
170 finally:
171 os.close(fd)
172 complete = staged['hash'] == r['hash']
173 state = 'upload-incomplete'
174 if complete:
175 state = 'recovery-available'
176 try:
177 td, name, t = target(r['path'])
178 try:
179 _, current = snapshot(t)
180 if current['hash'] == r['hash'] and preserved(current, r['baseline']):
181 state = 'confirmed'
182 elif current != r['baseline']:
183 state = 'conflict'
184 finally:
185 os.close(t)
186 os.close(td)
187 except OSError:
188 pass # Permission denied/disconnected from target: copy remains useful.
189 result = dict(recoveryId=value, path=r['path'], status=state, hash=staged['hash'], complete=complete)
190 if include_text:
191 if not complete:
192 fail('FILE_UPLOAD_INCOMPLETE')
193 result['data'] = base64.b64encode(data).decode('ascii')
194 return result
197def run(a):
198 global REPLACED
199 op = a['operation']
200 if op == 'read':
201 d, name, fd = target(a['path'])
202 try:
203 data, meta = snapshot(fd)
204 return dict(data=base64.b64encode(data).decode('ascii'), baseline=meta)
205 finally:
206 os.close(fd)
207 os.close(d)
208 d, home, uid = workspace(a.get('uid') if op != 'prepare' else None)
209 try:
210 if op == 'identity':
211 return dict(uid=uid)
212 if op == 'prepare':
213 tmp, journal = names(a['id'])
214 put_record(d, journal, dict(path=a['path'], baseline=a['baseline'], hash=a['hash']))
215 return dict(temporary=home + '/' + tmp, uid=uid, recoveryId=a['id'])
216 if op == 'list':
217 results = []
218 for name in sorted(os.listdir(d))[:10000]:
219 if re.fullmatch(r'\.[a-f0-9]{32}\.json', name):
220 try:
221 results.append(inspect_record(d, uid, name[1:-5]))
222 except (OSError, ValueError, KeyError):
223 results.append(dict(recoveryId=name[1:-5], status='inspection-unavailable'))
224 return dict(items=results)
225 tmp, journal = names(a['id'])
226 if op in ('inspect', 'cleanup'):
227 result = inspect_record(d, uid, a['id'], op == 'inspect')
228 if op == 'cleanup':
229 if result['status'] != 'confirmed':
230 fail('FILE_RESULT_UNCONFIRMED')
231 os.unlink(tmp, dir_fd=d)
232 os.unlink(journal, dir_fd=d)
233 os.fsync(d)
234 return result
235 if op != 'finalize':
236 fail('FILE_INVALID_OPERATION')
237 source = regular(d, tmp, uid, True)
238 try:
239 data, staged = snapshot(source)
240 if staged['hash'] != a['hash']:
241 fail('FILE_UPLOAD_INCOMPLETE')
242 os.fsync(source)
243 td, name, old = target(a['path'], os.geteuid() == 0)
244 destination = '.chj-save-' + secrets.token_hex(16) + '.tmp'
245 created = False
246 try:
247 fcntl.flock(old, fcntl.LOCK_EX | fcntl.LOCK_NB)
248 _, baseline = snapshot(old)
249 if baseline != a['baseline']:
250 fail('FILE_CONFLICT')
251 # Copying a content/inode-bound integrity signature would leave
252 # invalid security metadata. Re-signing requires a separate flow.
253 if set(baseline['xattrs']) & {'security.ima', 'security.evm'}:
254 fail('FILE_METADATA_UNSUPPORTED')
255 dest = os.open(destination, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=td)
256 created = True
257 try:
258 # Write fully before applying security metadata (capabilities
259 # and setuid bits can be cleared by writes or chown).
260 with os.fdopen(os.dup(dest), 'wb') as out:
261 out.write(data)
262 out.flush()
263 ds = os.fstat(dest)
264 if (ds.st_uid, ds.st_gid) != (baseline['uid'], baseline['gid']):
265 os.fchown(dest, baseline['uid'], baseline['gid'])
266 os.fchmod(dest, baseline['mode'])
267 for key in os.listxattr(dest):
268 if key not in baseline['xattrs']:
269 os.removexattr(dest, key)
270 for key, value in baseline['xattrs'].items():
271 os.setxattr(dest, key, base64.b64decode(value))
272 _, ready = snapshot(dest)
273 if ready['hash'] != a['hash'] or not preserved(ready, baseline):
274 fail('FILE_METADATA_UNSUPPORTED')
275 os.fsync(dest)
276 # Detect writes, chmod/chown, link changes and path replacement
277 # while preparing. flock serializes cooperating editor saves.
278 _, current = snapshot(old)
279 linked = os.stat(name, dir_fd=td, follow_symlinks=False)
280 if current != baseline or linked.st_ino != baseline['ino'] or linked.st_dev != baseline['dev'] or linked.st_nlink != 1:
281 fail('FILE_CONFLICT')
282 os.replace(destination, name, src_dir_fd=td, dst_dir_fd=td)
283 REPLACED = True
284 created = False
285 os.fsync(td)
286 check_dir, check_name, verified = target(a['path'])
287 try:
288 _, final = snapshot(verified)
289 finally:
290 os.close(verified)
291 os.close(check_dir)
292 if final['ino'] != ready['ino'] or final['hash'] != a['hash'] or not preserved(final, baseline):
293 fail('FILE_RESULT_UNCONFIRMED')
294 return dict(status='saved', baseline=final, hash=final['hash'])
295 finally:
296 os.close(dest)
297 finally:
298 if created:
299 try:
300 os.unlink(destination, dir_fd=td)
301 except OSError:
302 pass
303 os.close(old)
304 os.close(td)
305 finally:
306 os.close(source)
307 finally:
308 os.close(d)
311try:
312 if sys.platform != 'linux':
313 fail('FILE_LINUX_REQUIRED')
314 print(json.dumps(dict(ok=True, value=run(json.loads(sys.argv[1])))))
315except BlockingIOError:
316 print(json.dumps(dict(ok=False, code='FILE_CONFLICT')))
317except PermissionError:
318 print(json.dumps(dict(ok=False, code='FILE_RESULT_UNKNOWN' if REPLACED else 'FILE_PERMISSION_DENIED')))
319except FileNotFoundError:
320 print(json.dumps(dict(ok=False, code='FILE_RESULT_UNKNOWN' if REPLACED else 'FILE_NOT_FOUND')))
321except OSError as e:
322 code = 'FILE_UNSAFE_PATH' if e.errno in (errno.ELOOP, errno.ENOTDIR) else 'FILE_REMOTE_IO_FAILED'
323 print(json.dumps(dict(ok=False, code='FILE_RESULT_UNKNOWN' if REPLACED else code)))
324except (ValueError, KeyError, TypeError):
325 e = sys.exc_info()[1]
326 code = str(e) if re.fullmatch('FILE_[A-Z_]+', str(e)) else 'FILE_PROTOCOL_ERROR'
327 print(json.dumps(dict(ok=False, code='FILE_RESULT_UNKNOWN' if REPLACED else code)))

SHA-256: 64e39a9361357921026ee1e88fec63c55d785cf112af0e560d6177bfdd09409b

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0