teamsync-cluster 0.1.0__tar.gz

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,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: teamsync-cluster
3
+ Version: 0.1.0
4
+ Summary: Verified licensed TeamSync kit bootstrap
5
+ Author: TeamSync
6
+ License: Proprietary
7
+ Project-URL: Releases, https://release.teamsync.com.tw
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+
13
+ # TeamSync cluster bootstrap
14
+
15
+ Python >=3.8, no Python runtime dependencies. Install GnuPG (`gpg`) on the
16
+ connected download machine. Target kits support Linux amd64 and arm64.
17
+
18
+ ```sh
19
+ pip install teamsync-cluster
20
+ teamsync-cluster fetch --dest ./teamsync-download
21
+ # Hidden license prompt. Or --license-file /secure/license-key (0600), or
22
+ # TEAMSYNC_LICENSE_KEY from a secrets manager. --license KEY is also supported.
23
+ cd teamsync-download/kit
24
+ sudo ./teamsync-cluster config
25
+ sudo ./teamsync-cluster deploy --apply-firewall
26
+ sudo ./teamsync-cluster status
27
+ ```
28
+
29
+ `fetch` redeems once, downloads base plus only the licensed add-ons, verifies
30
+ the pinned GPG release signature and every selected SHA-256, verifies the
31
+ separately signed offline license, then extracts. The destination contains a
32
+ private download receipt with expiring URLs; rerun the same fetch destination
33
+ to resume without redeeming again. Keep it private. An interrupted response
34
+ before that receipt is saved requires TeamSync support to issue a new key.
35
+
36
+ Copy the complete destination to an offline site. Run the kit's executable
37
+ there; Python >=3.10 is required by the full installer. Other bootstrap
38
+ subcommands delegate only to `TEAMSYNC_KIT` or the last successful download
39
+ path. The bootstrap checks user/root ownership, write permissions, the signed
40
+ license version/architecture and the executable hash in `components/base.json`.
41
+ Use an administrator-owned directory outside shared writable paths such as `/tmp`.
42
+
43
+ For a different target architecture: `fetch --arch amd64` or `--arch arm64`.
44
+ For a pinned grant: `fetch --version vX.Y.Z`. A `latest` grant resolves the
45
+ latest released version when redeemed. Additional add-ons require a new key.
46
+ Expired download links require a new key; they cannot be renewed by replay.
47
+ The license key is never written to the destination or printed by this tool.
48
+
49
+ An expired term leaves `status` and `doctor` available with a warning. `deploy`
50
+ can repair the exact configuration authorized before expiry; new installs or
51
+ add-on changes require renewal. To renew with a **new key** without changing the
52
+ installed version or downloading archives (on a connected machine):
53
+
54
+ ```sh
55
+ teamsync-cluster fetch --license-only --kit ./teamsync-download/kit --license-file /secure/new-key
56
+ # For an offline site, copy only the renewed kit/license.json to the same kit.
57
+ ```
58
+
59
+ Published builds contact production only and pin its signing authority. Staging
60
+ uses a separate operator-only build; endpoint/key environment overrides are refused.
@@ -0,0 +1,48 @@
1
+ # TeamSync cluster bootstrap
2
+
3
+ Python >=3.8, no Python runtime dependencies. Install GnuPG (`gpg`) on the
4
+ connected download machine. Target kits support Linux amd64 and arm64.
5
+
6
+ ```sh
7
+ pip install teamsync-cluster
8
+ teamsync-cluster fetch --dest ./teamsync-download
9
+ # Hidden license prompt. Or --license-file /secure/license-key (0600), or
10
+ # TEAMSYNC_LICENSE_KEY from a secrets manager. --license KEY is also supported.
11
+ cd teamsync-download/kit
12
+ sudo ./teamsync-cluster config
13
+ sudo ./teamsync-cluster deploy --apply-firewall
14
+ sudo ./teamsync-cluster status
15
+ ```
16
+
17
+ `fetch` redeems once, downloads base plus only the licensed add-ons, verifies
18
+ the pinned GPG release signature and every selected SHA-256, verifies the
19
+ separately signed offline license, then extracts. The destination contains a
20
+ private download receipt with expiring URLs; rerun the same fetch destination
21
+ to resume without redeeming again. Keep it private. An interrupted response
22
+ before that receipt is saved requires TeamSync support to issue a new key.
23
+
24
+ Copy the complete destination to an offline site. Run the kit's executable
25
+ there; Python >=3.10 is required by the full installer. Other bootstrap
26
+ subcommands delegate only to `TEAMSYNC_KIT` or the last successful download
27
+ path. The bootstrap checks user/root ownership, write permissions, the signed
28
+ license version/architecture and the executable hash in `components/base.json`.
29
+ Use an administrator-owned directory outside shared writable paths such as `/tmp`.
30
+
31
+ For a different target architecture: `fetch --arch amd64` or `--arch arm64`.
32
+ For a pinned grant: `fetch --version vX.Y.Z`. A `latest` grant resolves the
33
+ latest released version when redeemed. Additional add-ons require a new key.
34
+ Expired download links require a new key; they cannot be renewed by replay.
35
+ The license key is never written to the destination or printed by this tool.
36
+
37
+ An expired term leaves `status` and `doctor` available with a warning. `deploy`
38
+ can repair the exact configuration authorized before expiry; new installs or
39
+ add-on changes require renewal. To renew with a **new key** without changing the
40
+ installed version or downloading archives (on a connected machine):
41
+
42
+ ```sh
43
+ teamsync-cluster fetch --license-only --kit ./teamsync-download/kit --license-file /secure/new-key
44
+ # For an offline site, copy only the renewed kit/license.json to the same kit.
45
+ ```
46
+
47
+ Published builds contact production only and pin its signing authority. Staging
48
+ uses a separate operator-only build; endpoint/key environment overrides are refused.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "teamsync-cluster"
7
+ version = "0.1.0"
8
+ description = "Verified licensed TeamSync kit bootstrap"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = {text = "Proprietary"}
12
+ authors = [{name = "TeamSync"}]
13
+ dependencies = []
14
+ classifiers = ["Programming Language :: Python :: 3", "Operating System :: OS Independent"]
15
+
16
+ [project.scripts]
17
+ teamsync-cluster = "teamsync_bootstrap.cli:main"
18
+
19
+ [project.urls]
20
+ Releases = "https://release.teamsync.com.tw"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """Public download bootstrap. Installer implementation stays in private kits."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,433 @@
1
+ """Python 3.8+ stdlib-only licensed downloader; never emits secret-bearing errors."""
2
+ import argparse
3
+ from contextlib import contextmanager
4
+ import getpass
5
+ import hashlib
6
+ import http.client
7
+ import json
8
+ import os
9
+ from pathlib import Path, PurePosixPath
10
+ import platform
11
+ import re
12
+ import shutil
13
+ import stat
14
+ import subprocess
15
+ import sys
16
+ import tarfile
17
+ import tempfile
18
+ import time
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+
23
+ from .license_verify import verify_license
24
+
25
+ REDEEM_URL = "https://api.cluster.scfg.io/public/distribution/licenses/redeem"
26
+ RELEASE_FINGERPRINT = "D31470AE995860024BD4427FB44C979CC1A80E3A"
27
+ VERSION = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]{0,40})?\Z")
28
+
29
+
30
+ class FetchError(Exception):
31
+ pass
32
+
33
+
34
+ class SecretSafeParser(argparse.ArgumentParser):
35
+ def error(self, message):
36
+ # argparse's default errors echo invalid/unrecognized argv, which may
37
+ # include a mistyped --license invocation. Never print those values.
38
+ self.print_usage(sys.stderr)
39
+ self.exit(2, "Invalid arguments. See teamsync-cluster fetch --help; key values withheld.\n")
40
+
41
+
42
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
43
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
44
+ raise FetchError("Unexpected download redirect; contact TeamSync support.")
45
+
46
+
47
+ def _https(url):
48
+ parsed = urllib.parse.urlsplit(url)
49
+ if (parsed.scheme != "https" or not (parsed.hostname or "").endswith(".r2.cloudflarestorage.com")
50
+ or parsed.port not in (None, 443) or parsed.username or parsed.password or parsed.fragment):
51
+ raise FetchError("Invalid download endpoint.")
52
+ return url
53
+
54
+
55
+ def _opener():
56
+ return urllib.request.build_opener(NoRedirect())
57
+
58
+
59
+ def _save(path, document):
60
+ temporary = path.with_name(path.name + ".new")
61
+ with os.fdopen(os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as stream:
62
+ json.dump(document, stream, ensure_ascii=True, sort_keys=True)
63
+ stream.flush()
64
+ os.fsync(stream.fileno())
65
+ os.replace(str(temporary), str(path))
66
+
67
+
68
+ @contextmanager
69
+ def _lock(destination):
70
+ path = destination / ".fetch.lock"
71
+ try:
72
+ path.mkdir()
73
+ except FileExistsError:
74
+ raise FetchError("Another fetch is active. If it stopped, remove .fetch.lock and retry.") from None
75
+ try:
76
+ yield
77
+ finally:
78
+ path.rmdir()
79
+
80
+
81
+ def _redeem(key, version, arch, license_only=False):
82
+ endpoint = REDEEM_URL
83
+ if os.environ.get("TEAMSYNC_LICENSE_ENDPOINT"):
84
+ raise FetchError("Endpoint overrides are not supported; use the separate operator staging build.")
85
+ body = {"key": key, "arch": [arch]}
86
+ if license_only:
87
+ body["license_only"] = True
88
+ if version:
89
+ body["version"] = version
90
+ try:
91
+ machine = Path("/etc/machine-id")
92
+ if machine.is_file():
93
+ body["host_fingerprint"] = hashlib.sha256(machine.read_bytes()).hexdigest()
94
+ request = urllib.request.Request(endpoint, data=json.dumps(body).encode(),
95
+ headers={"Content-Type": "application/json", "User-Agent": "teamsync-cluster-bootstrap/0.1"}, method="POST")
96
+ with _opener().open(request, timeout=60) as response:
97
+ data = response.read(262145)
98
+ if len(data) > 262144:
99
+ raise FetchError("License response exceeds limit.")
100
+ return json.loads(data)
101
+ except urllib.error.HTTPError as exc:
102
+ if exc.code == 403:
103
+ raise FetchError("License is invalid or unavailable. Contact TeamSync support.") from None
104
+ if exc.code == 429:
105
+ raise FetchError("Too many attempts. Wait one minute before retrying.") from None
106
+ raise FetchError("License service is unavailable. Contact TeamSync support before redeeming again.") from None
107
+
108
+
109
+ def _download(item, directory):
110
+ name = item["name"]
111
+ final, partial = directory / name, directory / (name + ".part")
112
+ if final.is_file():
113
+ return final
114
+ for attempt in range(3):
115
+ offset = partial.stat().st_size if partial.is_file() else 0
116
+ headers = {"Range": "bytes=%d-" % offset} if offset else {}
117
+ request = urllib.request.Request(_https(item["url"]), headers=headers)
118
+ try:
119
+ with _opener().open(request, timeout=60) as response:
120
+ append = offset > 0 and response.status == 206
121
+ if response.status == 206:
122
+ content_range = response.headers.get("Content-Range", "")
123
+ match = re.fullmatch(r"bytes ([0-9]+)-([0-9]+)/([0-9]+)", content_range)
124
+ if not match or int(match[1]) != offset:
125
+ raise FetchError("Invalid resume response.")
126
+ length = response.headers.get("Content-Length")
127
+ if length is None or not length.isdigit():
128
+ raise FetchError("Download has no valid Content-Length.")
129
+ expected = int(length)
130
+ received = 0
131
+ with partial.open("ab" if append else "wb") as output:
132
+ while True:
133
+ chunk = response.read(1024 * 1024)
134
+ if not chunk:
135
+ break
136
+ received += len(chunk)
137
+ output.write(chunk)
138
+ if received != expected:
139
+ raise OSError("Interrupted download")
140
+ os.replace(str(partial), str(final))
141
+ print("Downloaded: " + name)
142
+ return final
143
+ except urllib.error.HTTPError as exc:
144
+ if exc.code in (401, 403):
145
+ raise FetchError("Download window expired. Contact TeamSync support for a new key.") from None
146
+ if exc.code == 416:
147
+ # Cannot know whether a partial is complete until its signed
148
+ # checksum has been checked; safely restart this artifact.
149
+ partial.unlink(missing_ok=True)
150
+ except (OSError, urllib.error.URLError, http.client.HTTPException):
151
+ pass
152
+ if attempt < 2:
153
+ time.sleep(attempt + 1)
154
+ raise FetchError("Download interrupted. Rerun fetch with the same destination to resume.")
155
+
156
+
157
+ def signature_status_ok(status):
158
+ """Same NEWSIG/GOODSIG/VALIDSIG state machine as sign-release-checksums.sh."""
159
+ good = pinned = ok = bad = False
160
+ for line in status.splitlines():
161
+ words = line.split()
162
+ if len(words) < 2 or words[0] != "[GNUPG:]":
163
+ continue
164
+ kind = words[1]
165
+ if kind == "NEWSIG":
166
+ ok = ok or (good and pinned)
167
+ good = pinned = False
168
+ elif kind == "GOODSIG":
169
+ good = True
170
+ elif kind == "VALIDSIG" and words[-1] == RELEASE_FINGERPRINT:
171
+ pinned = True
172
+ elif kind in ("BADSIG", "ERRSIG", "EXPKEYSIG", "REVKEYSIG", "EXPSIG"):
173
+ bad = True
174
+ return (ok or (good and pinned)) and not bad
175
+
176
+
177
+ def _verify(directory, files):
178
+ with tempfile.TemporaryDirectory(prefix="teamsync-gpg-") as home:
179
+ command = ["gpg", "--no-options", "--batch", "--no-tty", "--homedir", home,
180
+ "--no-auto-key-retrieve"]
181
+ def gpg(*args):
182
+ return subprocess.run(command + list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
183
+ text=True, timeout=60)
184
+ key = str(directory / "teamsync-release-key.asc")
185
+ shown = gpg("--with-colons", "--show-keys", key)
186
+ pubs = [line for line in shown.stdout.splitlines() if line.startswith("pub:")]
187
+ fingerprints = [line.split(":")[9] for line in shown.stdout.splitlines() if line.startswith("fpr:")]
188
+ if shown.returncode or len(pubs) != 1 or not fingerprints or fingerprints[0] != RELEASE_FINGERPRINT:
189
+ raise FetchError("Release key fingerprint mismatch. Do not install.")
190
+ if gpg("--import", key).returncode:
191
+ raise FetchError("Release key import failed. Do not install.")
192
+ checked = gpg("--status-fd", "1", "--verify", str(directory / "SHA256SUMS.asc"), str(directory / "SHA256SUMS"))
193
+ if checked.returncode or not signature_status_ok(checked.stdout):
194
+ raise FetchError("Release signature verification failed. Do not install.")
195
+ print("SIGNATURE OK (" + RELEASE_FINGERPRINT + ")")
196
+ checksums = {}
197
+ for line in (directory / "SHA256SUMS").read_text(encoding="utf-8").splitlines():
198
+ match = re.fullmatch(r"([a-f0-9]{64}) (.+)", line)
199
+ if not match or match[2] in checksums:
200
+ raise FetchError("Invalid signed checksum list.")
201
+ checksums[match[2]] = match[1]
202
+ for item in files:
203
+ if item["kind"] in ("checksums", "signature", "public-key"):
204
+ continue
205
+ name = item["name"]
206
+ digest = hashlib.sha256()
207
+ with (directory / name).open("rb") as source:
208
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
209
+ digest.update(chunk)
210
+ if checksums.get(name) != digest.hexdigest():
211
+ # The next run can resume other files but must redownload corruption.
212
+ (directory / name).unlink()
213
+ raise FetchError("Checksum failed for " + name + ". Rerun fetch; do not install.")
214
+ print(name + ": OK")
215
+
216
+
217
+ def _validate_grant(grant, arch, requested):
218
+ version = grant["version"]
219
+ if not VERSION.fullmatch(version) or (requested and requested != version):
220
+ raise FetchError("Unexpected release version.")
221
+ try:
222
+ license_payload = verify_license(grant["license"], version=version, arch=arch)
223
+ except ValueError:
224
+ raise FetchError("Offline license is invalid or expired. Contact TeamSync support.") from None
225
+ clean = version[1:]
226
+ expected = {"SHA256SUMS": "checksums", "SHA256SUMS.asc": "signature",
227
+ "teamsync-release-key.asc": "public-key", "安裝指南.pdf": "guide",
228
+ "teamsync-base-%s-%s.tar.gz" % (clean, arch): "base"}
229
+ for addon in license_payload["addons"]:
230
+ expected["teamsync-addon-%s-%s-%s.tar.gz" % (addon, clean, arch)] = "addon"
231
+ files = grant["files"]
232
+ if len(files) != len(expected) or {item["name"]: item["kind"] for item in files} != expected:
233
+ raise FetchError("Download list does not match signed license entitlements.")
234
+ for item in files:
235
+ _https(item["url"])
236
+ return version
237
+
238
+
239
+ def _extract(directory, files, kits, version, arch, license_document):
240
+ root_name = "teamsync-%s-%s" % (version[1:], arch)
241
+ target = kits / "kit"
242
+ if target.exists():
243
+ raise FetchError("Kit directory already exists; choose a new destination for this download.")
244
+ kits.mkdir(exist_ok=True)
245
+ with tempfile.TemporaryDirectory(prefix=".extract-", dir=str(kits)) as temporary:
246
+ staging = Path(temporary)
247
+ written = set()
248
+ for item in sorted(files, key=lambda entry: entry["kind"] != "base"):
249
+ if item["kind"] not in ("base", "addon"):
250
+ continue
251
+ with tarfile.open(str(directory / item["name"]), "r:gz") as archive:
252
+ for member in archive:
253
+ path = PurePosixPath(member.name)
254
+ if (path.is_absolute() or "\\" in member.name or ":" in member.name
255
+ or not path.parts or path.parts[0] != root_name
256
+ or ".." in path.parts or not (member.isfile() or member.isdir())
257
+ or member.mode & 0o7000):
258
+ raise FetchError("Unsafe kit archive. Do not install.")
259
+ output = staging.joinpath(*path.parts)
260
+ if member.isdir():
261
+ output.mkdir(parents=True, exist_ok=True)
262
+ continue
263
+ if str(path) in written:
264
+ raise FetchError("Conflicting files in kit components. Do not install.")
265
+ written.add(str(path))
266
+ output.parent.mkdir(parents=True, exist_ok=True)
267
+ with archive.extractfile(member) as source, output.open("xb") as sink:
268
+ shutil.copyfileobj(source, sink)
269
+ output.chmod(member.mode & 0o777)
270
+ kit = staging / root_name
271
+ _save(kit / "license.json", license_document)
272
+ combined = {}
273
+ for component in ("base", *license_document["payload"]["addons"]):
274
+ receipt = kit / "system" / ("BUNDLE_FILES." + component + ".json")
275
+ entries = json.loads(receipt.read_text())["files"]
276
+ if combined.keys() & entries.keys():
277
+ raise FetchError("Conflicting component receipts.")
278
+ combined.update(entries)
279
+ _save(kit / "system/BUNDLE_FILES.json", {"format": 1, "files": combined})
280
+ shutil.copyfile(str(directory / "安裝指南.pdf"), str(kit / "安裝指南.pdf"))
281
+ if not (kit / "teamsync-cluster").is_file():
282
+ raise FetchError("Base kit is missing teamsync-cluster.")
283
+ os.replace(str(kit), str(target))
284
+ return target
285
+
286
+
287
+ def _license_key(args):
288
+ environment_key = os.environ.pop("TEAMSYNC_LICENSE_KEY", "")
289
+ key = args.license or environment_key
290
+ if args.license_file:
291
+ key = args.license_file.expanduser().read_text().strip()
292
+ if not key:
293
+ if not sys.stdin.isatty():
294
+ raise FetchError("Use --license-file or TEAMSYNC_LICENSE_KEY when no interactive terminal is available.")
295
+ key = getpass.getpass("TeamSync license key (hidden): ").strip()
296
+ if not key:
297
+ raise FetchError("A license key is required.")
298
+ return key
299
+
300
+
301
+ def fetch(args):
302
+ if args.license_only:
303
+ return _renew(args)
304
+ if args.kit is not None:
305
+ raise FetchError("--kit is used only with --license-only.")
306
+ if not shutil.which("gpg"):
307
+ raise FetchError("Install GnuPG (gpg) on this connected machine before redeeming.")
308
+ arch = args.arch
309
+ if arch == "auto":
310
+ arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(platform.machine().lower())
311
+ if arch not in ("amd64", "arm64"):
312
+ raise FetchError("Choose --arch amd64 or --arch arm64 for the target Linux host.")
313
+ destination = args.dest.expanduser().resolve()
314
+ destination.mkdir(mode=0o700, parents=True, exist_ok=True)
315
+ with _lock(destination):
316
+ receipt = destination / ".teamsync-fetch.json"
317
+ if receipt.is_file():
318
+ grant = json.loads(receipt.read_text())
319
+ else:
320
+ key = _license_key(args)
321
+ grant = _redeem(key, args.version, arch)
322
+ key = None
323
+ # Preserve URLs immediately, including if later verification fails;
324
+ # the license itself is not redeemed a second time on retry.
325
+ _save(receipt, grant)
326
+ version = _validate_grant(grant, arch, args.version)
327
+ directory = destination / "downloads"
328
+ directory.mkdir(mode=0o700, exist_ok=True)
329
+ for item in grant["files"]:
330
+ _download(item, directory)
331
+ _verify(directory, grant["files"])
332
+ target = destination / "kit"
333
+ if not target.exists():
334
+ target = _extract(directory, grant["files"], destination, version, arch, grant["license"])
335
+ config = Path.home() / ".config" / "teamsync-cluster"
336
+ config.mkdir(mode=0o700, parents=True, exist_ok=True)
337
+ _save(config / "kit-path.json", {"path": str(target)})
338
+ print("License signature: OK")
339
+ print("Ready: " + str(target))
340
+ print("On the target Linux machine: cd " + str(target) + "; sudo ./teamsync-cluster config")
341
+ return 0
342
+
343
+
344
+ def _secure_path(path):
345
+ path = Path(os.path.abspath(str(path.expanduser())))
346
+ for entry in (path, *path.parents):
347
+ info = entry.lstat()
348
+ if (stat.S_ISLNK(info.st_mode) or info.st_uid not in (0, os.getuid())
349
+ or info.st_mode & 0o022):
350
+ raise FetchError("Kit paths must be owned by you or root and must not be group/world writable or symlinks.")
351
+ return path
352
+
353
+
354
+ def _installed_kit(explicit=None):
355
+ selected = explicit or os.environ.get("TEAMSYNC_KIT")
356
+ if selected is None:
357
+ saved = Path.home() / ".config" / "teamsync-cluster" / "kit-path.json"
358
+ if not saved.is_file():
359
+ raise FetchError("Fetch a licensed kit first, or explicitly set TEAMSYNC_KIT.")
360
+ _secure_path(saved)
361
+ selected = json.loads(saved.read_text())["path"]
362
+ root = _secure_path(Path(selected))
363
+ executable = _secure_path(root / "teamsync-cluster")
364
+ marker = _secure_path(root / "components/base.json")
365
+ license_path = _secure_path(root / "license.json")
366
+ if not all(path.is_file() for path in (executable, marker, license_path)) or executable == Path(sys.argv[0]).resolve():
367
+ raise FetchError("Invalid installed kit.")
368
+ base = json.loads(marker.read_text())
369
+ if (base.get("component") != "base" or base.get("arch") not in ("amd64", "arm64")
370
+ or not VERSION.fullmatch(base.get("version", ""))
371
+ or hashlib.sha256(executable.read_bytes()).hexdigest() != base.get("cli_sha256")):
372
+ raise FetchError("Kit identity mismatch. Restore the verified base component.")
373
+ # Delegation must preserve access to the kit's diagnostics/repair grace.
374
+ # The real installer owns command-specific expiry and entitlement checks.
375
+ verify_license(json.loads(license_path.read_text()), version=base["version"], arch=base["arch"], allow_expired=True)
376
+ return root, base
377
+
378
+
379
+ def _renew(args):
380
+ root, base = _installed_kit(args.kit)
381
+ if args.version not in (None, base["version"]) or args.arch not in ("auto", base["arch"]):
382
+ raise FetchError("License-only renewal must match the installed kit version and architecture.")
383
+ with _lock(root):
384
+ grant = _redeem(_license_key(args), base["version"], base["arch"], license_only=True)
385
+ if grant.get("files") != [] or grant.get("version") != base["version"]:
386
+ raise FetchError("Unexpected license renewal response.")
387
+ verify_license(grant["license"], version=base["version"], arch=base["arch"])
388
+ _save(root / "license.json", grant["license"])
389
+ print("License renewed: " + base["version"])
390
+ return 0
391
+
392
+
393
+ def _delegate(argv):
394
+ root, _ = _installed_kit()
395
+ executable = root / "teamsync-cluster"
396
+ os.execv(str(executable), [str(executable)] + argv)
397
+
398
+
399
+ def main(argv=None):
400
+ argv = list(sys.argv[1:] if argv is None else argv)
401
+ try:
402
+ if argv and argv[0] not in ("fetch", "--help", "-h", "--version"):
403
+ if any(value in {"--license", "--license-file"} or value.startswith(("--license=", "--license-file="))
404
+ or re.search(r"tsl_[A-Za-z0-9_-]{43}", value) for value in argv):
405
+ raise FetchError("License key inputs are accepted only by fetch.")
406
+ os.environ.pop("TEAMSYNC_LICENSE_KEY", None)
407
+ return _delegate(argv)
408
+ parser = SecretSafeParser(prog="teamsync-cluster", description="Download a licensed TeamSync kit; other commands run its installer.")
409
+ parser.add_argument("--version", action="version", version="teamsync-cluster bootstrap 0.1.0")
410
+ sub = parser.add_subparsers(dest="command")
411
+ command = sub.add_parser("fetch", help="Redeem once, resume downloads, verify, then unpack")
412
+ keys = command.add_mutually_exclusive_group()
413
+ keys.add_argument("--license", metavar="KEY", help="Prefer a hidden prompt, --license-file or TEAMSYNC_LICENSE_KEY")
414
+ keys.add_argument("--license-file", type=Path)
415
+ command.add_argument("--license-only", action="store_true", help="Renew the installed version using a new key; no kit downloads")
416
+ command.add_argument("--kit", type=Path, help="Installed kit directory for --license-only renewal")
417
+ command.add_argument("--version", type=lambda value: value if VERSION.fullmatch(value) else parser.error("Use vX.Y.Z"))
418
+ command.add_argument("--arch", choices=("auto", "amd64", "arm64"), default="auto")
419
+ command.add_argument("--dest", type=Path, default=Path("teamsync-download"))
420
+ args = parser.parse_args(argv)
421
+ if args.command != "fetch":
422
+ parser.print_help()
423
+ return 0
424
+ return fetch(args)
425
+ except FetchError as exc:
426
+ print(str(exc), file=sys.stderr)
427
+ return 2
428
+ except (OSError, ValueError, KeyError, TypeError, urllib.error.URLError, http.client.HTTPException, subprocess.SubprocessError):
429
+ print("Fetch failed; credentials and URLs withheld. Keep the destination and contact TeamSync support.", file=sys.stderr)
430
+ return 2
431
+ except KeyboardInterrupt:
432
+ print("Stopped. Rerun fetch with the same destination to resume.", file=sys.stderr)
433
+ return 130
@@ -0,0 +1,59 @@
1
+ """Portable RSA/SHA-256 license verification (Python >=3.8, standard library).
2
+
3
+ This exact file is shipped in the public bootstrap and the kit zipapp. The
4
+ private half is LICENSE_SIGNING_PRIVATE_KEY in the cloud's Infisical project.
5
+ It is deliberately unrelated to the offline GPG release-signing key.
6
+ """
7
+ import base64
8
+ from datetime import datetime, timezone
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import re
13
+
14
+ PUBLIC_EXPONENT = 65537
15
+ LICENSE_ENVIRONMENT = "prod"
16
+ PUBLIC_MODULUS = int(
17
+ "9D64349F17D04C5996D1577BC22014B09DBCE4AC5AE9DDA147E70788D3094F7EC3BBA616A5649DB126453F6B21D3F56DCE4DCC40C63C396A04643C98B61A4A73B820928A6496FF0FAF1E0058144D48AF4F18909B7EF1E7CC04EEE512D8EFBB6EC4EE8356301C9AFF69203D735238E95289715275D461356350D6946D36AE838C917ACF609D45B2185E0E4237EA5DA1B8C41C461EDD2F8674632ECA58837DE67CA2964AE94B58A27D3EA133109F76A4942B49DF683700AB56BDA8D754D8E9695C953CA077F8D8DB3D2FCBADB59436FD9DD362FC220248872BE4359C5D59D8D22C852A1D97FF58B8CD64FBA377D38FD2E10E57B6AB5C7E943E3F17A208254B2F2F5AC58DF5AA8461852CBCEB0970F638F64BDC640D1CBC17F31D4F01E2336678AEC855C4F2CDF4F62297E1D549AB48C989CEB1A43099671C6EF154586C63058274BF80BF56B3E12AB077C38D3B34BC32BD31299FA8C36C0087F48A0209F99ED10AE238D6E8503D9E475F9EB7F50372B4E42E47C8B61838282B781FA601463C2FA9", 16)
18
+ ADDONS = frozenset(("jitsi", "edge-workers", "sandbox"))
19
+
20
+
21
+ def canonical(payload):
22
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
23
+
24
+
25
+ def verify_license(document, version=None, arch=None, now=None, allow_expired=False):
26
+ """Strict PKCS#1 v1.5 verification; no network, key discovery or overrides."""
27
+ try:
28
+ if set(document) != {"payload", "signature"}:
29
+ raise ValueError()
30
+ payload = document["payload"]
31
+ if set(payload) != {"schema_version", "environment", "key_id", "customer", "addons", "arches", "version", "valid_until"}:
32
+ raise ValueError()
33
+ signature = base64.b64decode(document["signature"], validate=True)
34
+ size = (PUBLIC_MODULUS.bit_length() + 7) // 8
35
+ number = int.from_bytes(signature, "big")
36
+ if len(signature) != size or number >= PUBLIC_MODULUS:
37
+ raise ValueError()
38
+ digest_info = bytes.fromhex("3031300d060960864801650304020105000420") + hashlib.sha256(canonical(payload)).digest()
39
+ expected = b"\x00\x01" + b"\xff" * (size - len(digest_info) - 3) + b"\x00" + digest_info
40
+ actual = pow(number, PUBLIC_EXPONENT, PUBLIC_MODULUS).to_bytes(size, "big")
41
+ if not hmac.compare_digest(actual, expected):
42
+ raise ValueError()
43
+ until = datetime.fromisoformat(payload["valid_until"].replace("Z", "+00:00"))
44
+ if until.tzinfo is None or (not allow_expired and until <= (now or datetime.now(timezone.utc))):
45
+ raise ValueError()
46
+ if (payload["schema_version"] != 1 or payload["environment"] != LICENSE_ENVIRONMENT
47
+ or not isinstance(payload["customer"], str)
48
+ or not re.fullmatch(r"[a-f0-9-]{36}", payload["key_id"])
49
+ or not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]{0,40})?", payload["version"])
50
+ or not isinstance(payload["addons"], list) or len(set(payload["addons"])) != len(payload["addons"])
51
+ or not set(payload["addons"]) <= ADDONS
52
+ or not isinstance(payload["arches"], list) or not payload["arches"]
53
+ or not set(payload["arches"]) <= {"amd64", "arm64"}
54
+ or (version is not None and version != payload["version"])
55
+ or (arch is not None and arch not in payload["arches"])):
56
+ raise ValueError()
57
+ return payload
58
+ except (ValueError, TypeError, KeyError, OverflowError, AttributeError):
59
+ raise ValueError("License is invalid, expired or does not cover this kit.") from None
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: teamsync-cluster
3
+ Version: 0.1.0
4
+ Summary: Verified licensed TeamSync kit bootstrap
5
+ Author: TeamSync
6
+ License: Proprietary
7
+ Project-URL: Releases, https://release.teamsync.com.tw
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+
13
+ # TeamSync cluster bootstrap
14
+
15
+ Python >=3.8, no Python runtime dependencies. Install GnuPG (`gpg`) on the
16
+ connected download machine. Target kits support Linux amd64 and arm64.
17
+
18
+ ```sh
19
+ pip install teamsync-cluster
20
+ teamsync-cluster fetch --dest ./teamsync-download
21
+ # Hidden license prompt. Or --license-file /secure/license-key (0600), or
22
+ # TEAMSYNC_LICENSE_KEY from a secrets manager. --license KEY is also supported.
23
+ cd teamsync-download/kit
24
+ sudo ./teamsync-cluster config
25
+ sudo ./teamsync-cluster deploy --apply-firewall
26
+ sudo ./teamsync-cluster status
27
+ ```
28
+
29
+ `fetch` redeems once, downloads base plus only the licensed add-ons, verifies
30
+ the pinned GPG release signature and every selected SHA-256, verifies the
31
+ separately signed offline license, then extracts. The destination contains a
32
+ private download receipt with expiring URLs; rerun the same fetch destination
33
+ to resume without redeeming again. Keep it private. An interrupted response
34
+ before that receipt is saved requires TeamSync support to issue a new key.
35
+
36
+ Copy the complete destination to an offline site. Run the kit's executable
37
+ there; Python >=3.10 is required by the full installer. Other bootstrap
38
+ subcommands delegate only to `TEAMSYNC_KIT` or the last successful download
39
+ path. The bootstrap checks user/root ownership, write permissions, the signed
40
+ license version/architecture and the executable hash in `components/base.json`.
41
+ Use an administrator-owned directory outside shared writable paths such as `/tmp`.
42
+
43
+ For a different target architecture: `fetch --arch amd64` or `--arch arm64`.
44
+ For a pinned grant: `fetch --version vX.Y.Z`. A `latest` grant resolves the
45
+ latest released version when redeemed. Additional add-ons require a new key.
46
+ Expired download links require a new key; they cannot be renewed by replay.
47
+ The license key is never written to the destination or printed by this tool.
48
+
49
+ An expired term leaves `status` and `doctor` available with a warning. `deploy`
50
+ can repair the exact configuration authorized before expiry; new installs or
51
+ add-on changes require renewal. To renew with a **new key** without changing the
52
+ installed version or downloading archives (on a connected machine):
53
+
54
+ ```sh
55
+ teamsync-cluster fetch --license-only --kit ./teamsync-download/kit --license-file /secure/new-key
56
+ # For an offline site, copy only the renewed kit/license.json to the same kit.
57
+ ```
58
+
59
+ Published builds contact production only and pin its signing authority. Staging
60
+ uses a separate operator-only build; endpoint/key environment overrides are refused.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/teamsync_bootstrap/__init__.py
4
+ src/teamsync_bootstrap/__main__.py
5
+ src/teamsync_bootstrap/cli.py
6
+ src/teamsync_bootstrap/license_verify.py
7
+ src/teamsync_cluster.egg-info/PKG-INFO
8
+ src/teamsync_cluster.egg-info/SOURCES.txt
9
+ src/teamsync_cluster.egg-info/dependency_links.txt
10
+ src/teamsync_cluster.egg-info/entry_points.txt
11
+ src/teamsync_cluster.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ teamsync-cluster = teamsync_bootstrap.cli:main
@@ -0,0 +1 @@
1
+ teamsync_bootstrap