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.
- teamsync_cluster-0.1.0/PKG-INFO +60 -0
- teamsync_cluster-0.1.0/README.md +48 -0
- teamsync_cluster-0.1.0/pyproject.toml +23 -0
- teamsync_cluster-0.1.0/setup.cfg +4 -0
- teamsync_cluster-0.1.0/src/teamsync_bootstrap/__init__.py +2 -0
- teamsync_cluster-0.1.0/src/teamsync_bootstrap/__main__.py +3 -0
- teamsync_cluster-0.1.0/src/teamsync_bootstrap/cli.py +433 -0
- teamsync_cluster-0.1.0/src/teamsync_bootstrap/license_verify.py +59 -0
- teamsync_cluster-0.1.0/src/teamsync_cluster.egg-info/PKG-INFO +60 -0
- teamsync_cluster-0.1.0/src/teamsync_cluster.egg-info/SOURCES.txt +11 -0
- teamsync_cluster-0.1.0/src/teamsync_cluster.egg-info/dependency_links.txt +1 -0
- teamsync_cluster-0.1.0/src/teamsync_cluster.egg-info/entry_points.txt +2 -0
- teamsync_cluster-0.1.0/src/teamsync_cluster.egg-info/top_level.txt +1 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
teamsync_bootstrap
|