code-server-sync 0.0.0-stage → 0.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.
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import hmac
5
+ import json
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import threading
10
+ import time
11
+ import urllib.parse
12
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
+
14
+ from cs_sync.files import FileIndex, remove_file, safe_destination, write_file
15
+ from cs_sync.git import StageError, collect_changes, stage_changes
16
+ from cs_sync.protocol import (
17
+ DEFAULT_PORT,
18
+ MAX_BYTES,
19
+ ROUTE_CHANGES,
20
+ ROUTE_DELETE,
21
+ ROUTE_FILE,
22
+ ROUTE_HEALTH,
23
+ ROUTE_IGNORE,
24
+ ROUTE_MANIFEST,
25
+ ROUTE_PULL,
26
+ ROUTE_STAGE,
27
+ eprint,
28
+ route_of,
29
+ )
30
+
31
+ def make_handler(root: str, token: str, index: FileIndex):
32
+ class Handler(BaseHTTPRequestHandler):
33
+ protocol_version = "HTTP/1.1"
34
+
35
+ def log_message(self, fmt: str, *args) -> None:
36
+ return
37
+
38
+ def _send(self, status: int, body: bytes, content_type: str = "text/plain; charset=utf-8") -> None:
39
+ self.send_response(status)
40
+ self.send_header("Content-Type", content_type)
41
+ self.send_header("Content-Length", str(len(body)))
42
+ self.send_header("Cache-Control", "no-store")
43
+ self.end_headers()
44
+ self.wfile.write(body)
45
+
46
+ def _authorized(self) -> bool:
47
+ given = self.headers.get("X-Sync-Token", "")
48
+ if not token or len(given) != len(token):
49
+ return False
50
+ return hmac.compare_digest(given, token)
51
+
52
+ def _rel_path(self) -> str:
53
+ raw = self.headers.get("X-Rel-Path", "")
54
+ if not raw:
55
+ raise ValueError("缺少 X-Rel-Path")
56
+ return urllib.parse.unquote(raw)
57
+
58
+ def _read_body(self) -> bytes:
59
+ length_raw = self.headers.get("Content-Length")
60
+ if length_raw is None:
61
+ raise ValueError("缺少 Content-Length")
62
+ length = int(length_raw)
63
+ if length < 0 or length > MAX_BYTES:
64
+ raise ValueError("文件过大")
65
+ payload = self.rfile.read(length) if length else b""
66
+ if len(payload) != length:
67
+ raise ValueError("请求体不完整")
68
+ return payload
69
+
70
+ def do_GET(self) -> None: # noqa: N802
71
+ route = route_of(self.path)
72
+ if route == ROUTE_HEALTH:
73
+ self._send(200, b"ok\n")
74
+ return
75
+ if not self._authorized():
76
+ self._send(403, b"bad token\n")
77
+ return
78
+ if route == ROUTE_MANIFEST:
79
+ body = json.dumps({"files": index.manifest()}).encode("utf-8")
80
+ self._send(200, body, "application/json")
81
+ return
82
+ if route == ROUTE_PULL:
83
+ try:
84
+ rel = self._rel_path()
85
+ dest = safe_destination(root, rel)
86
+ if not os.path.isfile(dest) or os.path.islink(dest):
87
+ self._send(404, b"missing\n")
88
+ return
89
+ if os.path.getsize(dest) > MAX_BYTES:
90
+ self._send(400, ("文件过大\n").encode("utf-8"))
91
+ return
92
+ with open(dest, "rb") as handle:
93
+ payload = handle.read(MAX_BYTES + 1)
94
+ if len(payload) > MAX_BYTES:
95
+ self._send(400, ("文件过大\n").encode("utf-8"))
96
+ return
97
+ mode = "755" if os.stat(dest).st_mode & 0o111 else "644"
98
+ except (ValueError, OSError) as exc:
99
+ self._send(400, (str(exc) + "\n").encode("utf-8"))
100
+ return
101
+ self.send_response(200)
102
+ self.send_header("Content-Type", "application/octet-stream")
103
+ self.send_header("Content-Length", str(len(payload)))
104
+ self.send_header("X-File-Mode", mode)
105
+ self.send_header("Cache-Control", "no-store")
106
+ self.end_headers()
107
+ self.wfile.write(payload)
108
+ return
109
+ self._send(404, b"not found\n")
110
+
111
+ def do_POST(self) -> None: # noqa: N802
112
+ route = route_of(self.path)
113
+ if route not in (ROUTE_FILE, ROUTE_DELETE, ROUTE_IGNORE, ROUTE_STAGE, ROUTE_CHANGES):
114
+ self._send(404, b"not found\n")
115
+ return
116
+ if not self._authorized():
117
+ self._send(403, b"bad token\n")
118
+ return
119
+ try:
120
+ if route == ROUTE_STAGE:
121
+ if self.headers.get("Content-Length") not in (None, "0"):
122
+ self._read_body()
123
+ try:
124
+ staged, repo_count = stage_changes(root, set(index.ignore))
125
+ except StageError as exc:
126
+ self._send(500, (str(exc) + "\n").encode("utf-8"))
127
+ return
128
+ body = json.dumps({"staged": staged, "repos": repo_count}).encode("utf-8")
129
+ self._send(200, body, "application/json")
130
+ return
131
+ if route == ROUTE_CHANGES:
132
+ if self.headers.get("Content-Length") not in (None, "0"):
133
+ self._read_body()
134
+ try:
135
+ changes, repo_count = collect_changes(root, set(index.ignore))
136
+ except StageError as exc:
137
+ self._send(500, (str(exc) + "\n").encode("utf-8"))
138
+ return
139
+ body = json.dumps({"changes": changes, "repos": repo_count}).encode("utf-8")
140
+ self._send(200, body, "application/json")
141
+ return
142
+ if route == ROUTE_IGNORE:
143
+ payload = self._read_body()
144
+ names = json.loads(payload.decode("utf-8")).get("dirs") or []
145
+ index.set_ignore(names)
146
+ index.scan()
147
+ self._send(200, b"ok\n")
148
+ return
149
+ rel = self._rel_path()
150
+ if route == ROUTE_FILE:
151
+ payload = self._read_body()
152
+ mode = 0o644
153
+ mode_raw = self.headers.get("X-File-Mode", "644")
154
+ if mode_raw == "755":
155
+ mode = 0o755
156
+ write_file(root, rel, payload, mode)
157
+ index.note_write(rel, payload)
158
+ print("云端写入 %s (%d 字节)" % (rel, len(payload)), flush=True)
159
+ else:
160
+ if self.headers.get("Content-Length") not in (None, "0"):
161
+ self._read_body()
162
+ remove_file(root, rel)
163
+ index.note_delete(rel)
164
+ print("云端写入(删除) %s" % rel, flush=True)
165
+ except ValueError as exc:
166
+ self._send(400, (str(exc) + "\n").encode("utf-8"))
167
+ return
168
+ except OSError as exc:
169
+ self._send(500, (str(exc) + "\n").encode("utf-8"))
170
+ return
171
+ self._send(200, b"ok\n")
172
+
173
+ return Handler
174
+
175
+
176
+ class PortProbeError(Exception):
177
+ pass
178
+
179
+
180
+ def listening_pids_linux(port: int) -> list:
181
+ hexport = "%04X" % port
182
+ inodes = set()
183
+ for path in ("/proc/net/tcp", "/proc/net/tcp6"):
184
+ try:
185
+ lines = open(path).read().splitlines()[1:]
186
+ except OSError:
187
+ continue
188
+ for line in lines:
189
+ parts = line.split()
190
+ if len(parts) < 10:
191
+ continue
192
+ local, state, inode = parts[1], parts[3], parts[9]
193
+ if local.rsplit(":", 1)[-1].upper() == hexport and state == "0A":
194
+ inodes.add(inode)
195
+ pids = []
196
+ try:
197
+ proc_names = os.listdir("/proc")
198
+ except OSError:
199
+ return pids
200
+ for name in proc_names:
201
+ if not name.isdigit():
202
+ continue
203
+ fd_dir = "/proc/%s/fd" % name
204
+ try:
205
+ fds = os.listdir(fd_dir)
206
+ except OSError:
207
+ continue
208
+ for fd in fds:
209
+ try:
210
+ target = os.readlink("%s/%s" % (fd_dir, fd))
211
+ except OSError:
212
+ continue
213
+ if target.startswith("socket:[") and target[8:-1] in inodes:
214
+ pids.append(int(name))
215
+ break
216
+ return pids
217
+
218
+
219
+ def listening_pids_lsof(port: int) -> list:
220
+ try:
221
+ result = subprocess.run(
222
+ ["lsof", "-nP", "-t", "-iTCP:%d" % port, "-sTCP:LISTEN"],
223
+ stdout=subprocess.PIPE,
224
+ stderr=subprocess.PIPE,
225
+ check=False,
226
+ )
227
+ except OSError as exc:
228
+ raise PortProbeError("无法执行 lsof:%s" % exc)
229
+ # lsof 在没有匹配时退出码为 1
230
+ if result.returncode not in (0, 1):
231
+ detail = result.stderr.decode("utf-8", errors="replace").strip()
232
+ raise PortProbeError(detail or ("lsof 失败,退出码 %d" % result.returncode))
233
+ pids = []
234
+ for line in result.stdout.decode("utf-8", errors="replace").splitlines():
235
+ text = line.strip()
236
+ if text.isdigit():
237
+ pids.append(int(text))
238
+ return pids
239
+
240
+
241
+ def listening_pids(port: int) -> list:
242
+ if os.path.isdir("/proc") and os.path.exists("/proc/net/tcp"):
243
+ return listening_pids_linux(port)
244
+ return listening_pids_lsof(port)
245
+
246
+
247
+ def process_command_line(pid: int) -> str:
248
+ try:
249
+ return open("/proc/%d/cmdline" % pid, "rb").read().replace(b"\0", b" ").decode()
250
+ except OSError:
251
+ pass
252
+ try:
253
+ result = subprocess.run(
254
+ ["ps", "-p", str(pid), "-o", "command="],
255
+ stdout=subprocess.PIPE,
256
+ stderr=subprocess.PIPE,
257
+ check=False,
258
+ )
259
+ except OSError:
260
+ return ""
261
+ if result.returncode != 0:
262
+ return ""
263
+ return result.stdout.decode("utf-8", errors="replace").strip()
264
+
265
+
266
+ def is_our_recv(command: str) -> bool:
267
+ text = command or ""
268
+ if "cs-sync.py" in text:
269
+ return True
270
+ return "cs_sync" in text and "recv" in text
271
+
272
+
273
+ def release_sync_port(port: int) -> None:
274
+ try:
275
+ found = listening_pids(port)
276
+ except PortProbeError as exc:
277
+ raise SystemExit(
278
+ "端口 %d 被占用,但无法列出监听进程:%s\n"
279
+ "请手动检查并结束后再启动:lsof -nP -iTCP:%d -sTCP:LISTEN"
280
+ % (port, exc, port)
281
+ )
282
+ pids = [pid for pid in found if pid != os.getpid()]
283
+ if not pids:
284
+ raise SystemExit(
285
+ "端口 %d 被占用,但未找到可停止的监听进程。\n"
286
+ "请手动检查:lsof -nP -iTCP:%d -sTCP:LISTEN"
287
+ % (port, port)
288
+ )
289
+ for pid in pids:
290
+ command = process_command_line(pid)
291
+ if not is_our_recv(command):
292
+ raise SystemExit("端口 %d 被其他程序占用:%s %s" % (port, pid, command))
293
+ print("停掉已在端口 %d 上运行的接收程序 %d" % (port, pid), flush=True)
294
+ os.kill(pid, signal.SIGTERM)
295
+ for _ in range(50):
296
+ try:
297
+ remaining = listening_pids(port)
298
+ except PortProbeError:
299
+ break
300
+ if not [pid for pid in remaining if pid != os.getpid()]:
301
+ return
302
+ time.sleep(0.1)
303
+ try:
304
+ remaining = listening_pids(port)
305
+ except PortProbeError as exc:
306
+ eprint("端口 %d 仍可能被占用,且无法再次列出进程:%s" % (port, exc))
307
+ return
308
+ for pid in remaining:
309
+ if pid == os.getpid():
310
+ continue
311
+ try:
312
+ os.kill(pid, signal.SIGKILL)
313
+ except OSError:
314
+ pass
315
+ time.sleep(0.2)
316
+
317
+
318
+ class ReuseHTTPServer(ThreadingHTTPServer):
319
+ allow_reuse_address = True
320
+
321
+
322
+ def serve(root: str, token: str, port: int) -> None:
323
+ if not os.path.isabs(root):
324
+ raise SystemExit("云端项目路径必须是绝对路径")
325
+ if len(token) < 16:
326
+ raise SystemExit("令牌太短")
327
+ os.makedirs(root, exist_ok=True)
328
+ index = FileIndex(root)
329
+ index.scan()
330
+
331
+ def refresh() -> None:
332
+ while True:
333
+ time.sleep(1)
334
+ try:
335
+ index.scan(announce=True)
336
+ except OSError as exc:
337
+ eprint("扫描云端目录失败: %s" % exc)
338
+
339
+ threading.Thread(target=refresh, daemon=True).start()
340
+ try:
341
+ server = ReuseHTTPServer(("0.0.0.0", port), make_handler(root, token, index))
342
+ except OSError as exc:
343
+ if getattr(exc, "errno", None) not in (errno.EADDRINUSE, 98):
344
+ raise
345
+ release_sync_port(port)
346
+ try:
347
+ server = ReuseHTTPServer(("0.0.0.0", port), make_handler(root, token, index))
348
+ except OSError as again:
349
+ if getattr(again, "errno", None) in (errno.EADDRINUSE, 98):
350
+ raise SystemExit("端口 %d 仍被占用。先停掉旧的 cs-sync recv,再启动。" % port)
351
+ raise
352
+ server.daemon_threads = True
353
+ print("接收程序已听在 0.0.0.0:%d" % port, flush=True)
354
+ print("项目目录: %s" % root, flush=True)
355
+ print("本机经 code-server 访问时使用 /proxy/%d/" % port, flush=True)
356
+ try:
357
+ server.serve_forever()
358
+ except KeyboardInterrupt:
359
+ print("\n已停止", flush=True)
360
+ server.server_close()