touchlab-license-server 1.0.0__py3-none-any.whl
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.
Potentially problematic release.
This version of touchlab-license-server might be problematic. Click here for more details.
- touchlab_license_server/__init__.py +0 -0
- touchlab_license_server/cli.py +185 -0
- touchlab_license_server/client.py +94 -0
- touchlab_license_server/crypto.py +72 -0
- touchlab_license_server/importer.py +48 -0
- touchlab_license_server/paths.py +56 -0
- touchlab_license_server/protocol.py +72 -0
- touchlab_license_server/resources/touchlab-license-server.service +12 -0
- touchlab_license_server/server.py +95 -0
- touchlab_license_server/store.py +104 -0
- touchlab_license_server/transport.py +56 -0
- touchlab_license_server-1.0.0.dist-info/METADATA +77 -0
- touchlab_license_server-1.0.0.dist-info/RECORD +17 -0
- touchlab_license_server-1.0.0.dist-info/WHEEL +5 -0
- touchlab_license_server-1.0.0.dist-info/entry_points.txt +2 -0
- touchlab_license_server-1.0.0.dist-info/licenses/LICENSE +76 -0
- touchlab_license_server-1.0.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""`touchlab-license-server` entrypoint: start / stop / status / list.
|
|
2
|
+
|
|
3
|
+
Also serves as the reference client for the socket protocol — anything
|
|
4
|
+
that wants to talk to the daemon (touchlab-slip-detect, this CLI)
|
|
5
|
+
sends the same REGISTER/CHECK/LIST/PING frames defined in protocol.py.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import importlib.resources
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import socket
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from . import client, paths, protocol, server, transport
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _connect(timeout=2.0) -> socket.socket:
|
|
21
|
+
return transport.connect(timeout)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _detach_kwargs() -> dict:
|
|
25
|
+
"""Popen arguments that let the daemon outlive the command that started it."""
|
|
26
|
+
if paths.WINDOWS:
|
|
27
|
+
import subprocess
|
|
28
|
+
return {"creationflags": subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
29
|
+
return {"start_new_session": True}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_running() -> bool:
|
|
33
|
+
try:
|
|
34
|
+
with _connect(timeout=1.0) as sock:
|
|
35
|
+
resp = protocol.request(sock, protocol.PING)
|
|
36
|
+
return bool(resp.get("ok"))
|
|
37
|
+
except (OSError, protocol.ProtocolError):
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_start(args):
|
|
42
|
+
if _is_running():
|
|
43
|
+
print(f"already running ({transport.describe()})")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
if args.foreground:
|
|
47
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
48
|
+
server.main_async()
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
# Detach: re-exec ourselves with --foreground, logs to a file, pid tracked for `stop`.
|
|
52
|
+
log_file = open(paths.log_path(), "a")
|
|
53
|
+
proc = __import__("subprocess").Popen(
|
|
54
|
+
[sys.executable, "-m", "touchlab_license_server.cli", "start", "--foreground"],
|
|
55
|
+
stdout=log_file,
|
|
56
|
+
stderr=log_file,
|
|
57
|
+
**_detach_kwargs(),
|
|
58
|
+
)
|
|
59
|
+
paths.pid_path().write_text(str(proc.pid))
|
|
60
|
+
|
|
61
|
+
deadline = time.monotonic() + client.START_TIMEOUT_SEC
|
|
62
|
+
while time.monotonic() < deadline:
|
|
63
|
+
if _is_running():
|
|
64
|
+
print(f"started (pid {proc.pid}, {transport.describe()})")
|
|
65
|
+
return 0
|
|
66
|
+
if proc.poll() is not None: # the server exited: it won't come up
|
|
67
|
+
break
|
|
68
|
+
time.sleep(0.1)
|
|
69
|
+
|
|
70
|
+
print(f"started process (pid {proc.pid}) but it isn't answering. {client.startup_failure()}")
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_stop(args):
|
|
75
|
+
if not paths.pid_path().exists():
|
|
76
|
+
print("not running (no pidfile)")
|
|
77
|
+
return 0
|
|
78
|
+
pid = int(paths.pid_path().read_text().strip())
|
|
79
|
+
try:
|
|
80
|
+
os.kill(pid, signal.SIGTERM) # on Windows this terminates the process
|
|
81
|
+
print(f"stopped (pid {pid})")
|
|
82
|
+
except OSError:
|
|
83
|
+
print(f"pid {pid} not found (stale pidfile)")
|
|
84
|
+
paths.pid_path().unlink(missing_ok=True)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_status(args):
|
|
89
|
+
if _is_running():
|
|
90
|
+
print(f"running ({transport.describe()})")
|
|
91
|
+
return 0
|
|
92
|
+
print("not running")
|
|
93
|
+
return 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _connect_starting_server() -> socket.socket:
|
|
97
|
+
"""Connect, starting the server first if it isn't running (e.g. after a
|
|
98
|
+
reboot, before touchlab-slip-detect has run)."""
|
|
99
|
+
if not client.ensure_server_running():
|
|
100
|
+
print(f"error: {client.startup_failure()}", file=sys.stderr)
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
return _connect()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_list(args):
|
|
106
|
+
with _connect_starting_server() as sock:
|
|
107
|
+
protocol.request(sock, protocol.SYNC) # include a license activated moments ago
|
|
108
|
+
resp = protocol.request(sock, protocol.LIST)
|
|
109
|
+
if not resp.get("ok"):
|
|
110
|
+
print(f"error: {resp.get('error')}")
|
|
111
|
+
return 1
|
|
112
|
+
licenses = resp["licenses"]
|
|
113
|
+
if not licenses:
|
|
114
|
+
print("no licenses registered")
|
|
115
|
+
return 0
|
|
116
|
+
for lic in licenses:
|
|
117
|
+
if lic["licensed"]:
|
|
118
|
+
days = lic.get("days_remaining")
|
|
119
|
+
left = "perpetual" if days is None else f"{days} day(s) left"
|
|
120
|
+
print(f"{lic['sn']} licensed=True expires_at={lic['expires_at']} ({left})")
|
|
121
|
+
else:
|
|
122
|
+
print(f"{lic['sn']} licensed=False expires_at={lic['expires_at']} ({lic['reason']})")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cmd_sync(args):
|
|
127
|
+
"""Re-scan ~/.config/touchlab/licenses now instead of waiting for the
|
|
128
|
+
background sync — handy right after running touchlab-activate."""
|
|
129
|
+
with _connect_starting_server() as sock:
|
|
130
|
+
resp = protocol.request(sock, protocol.SYNC)
|
|
131
|
+
if not resp.get("ok"):
|
|
132
|
+
print(f"error: {resp.get('error')}")
|
|
133
|
+
return 1
|
|
134
|
+
imported = resp["imported"]
|
|
135
|
+
print(f"synced {len(imported)} license(s): {', '.join(imported) if imported else '(none)'}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_install_service(args):
|
|
140
|
+
"""Install the systemd --user unit so the daemon starts automatically at login."""
|
|
141
|
+
if paths.WINDOWS:
|
|
142
|
+
print("Not needed on Windows: the license server starts on its own whenever "
|
|
143
|
+
"touchlab-slip-detect or touchlab-license-server needs it.")
|
|
144
|
+
return 0
|
|
145
|
+
unit_src = importlib.resources.files("touchlab_license_server").joinpath(
|
|
146
|
+
"resources", "touchlab-license-server.service"
|
|
147
|
+
)
|
|
148
|
+
unit_dir = os.path.expanduser("~/.config/systemd/user")
|
|
149
|
+
os.makedirs(unit_dir, exist_ok=True)
|
|
150
|
+
unit_dest = os.path.join(unit_dir, "touchlab-license-server.service")
|
|
151
|
+
|
|
152
|
+
with importlib.resources.as_file(unit_src) as unit_path:
|
|
153
|
+
contents = unit_path.read_text()
|
|
154
|
+
with open(unit_dest, "w") as f:
|
|
155
|
+
f.write(contents)
|
|
156
|
+
|
|
157
|
+
print(f"wrote {unit_dest}")
|
|
158
|
+
print("Enable it with:")
|
|
159
|
+
print(" systemctl --user daemon-reload")
|
|
160
|
+
print(" systemctl --user enable --now touchlab-license-server")
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main():
|
|
165
|
+
parser = argparse.ArgumentParser(prog="touchlab-license-server")
|
|
166
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
167
|
+
|
|
168
|
+
p_start = sub.add_parser("start", help="start the daemon")
|
|
169
|
+
p_start.add_argument("--foreground", action="store_true", help="run in this process instead of detaching")
|
|
170
|
+
p_start.set_defaults(func=cmd_start)
|
|
171
|
+
|
|
172
|
+
sub.add_parser("stop", help="stop the daemon").set_defaults(func=cmd_stop)
|
|
173
|
+
sub.add_parser("status", help="check whether the daemon is running").set_defaults(func=cmd_status)
|
|
174
|
+
sub.add_parser("list", help="list registered licenses").set_defaults(func=cmd_list)
|
|
175
|
+
sub.add_parser("sync", help="re-scan ~/.config/touchlab/licenses now").set_defaults(func=cmd_sync)
|
|
176
|
+
sub.add_parser(
|
|
177
|
+
"install-service", help="install the systemd --user unit for autostart at login"
|
|
178
|
+
).set_defaults(func=cmd_install_service)
|
|
179
|
+
|
|
180
|
+
args = parser.parse_args()
|
|
181
|
+
sys.exit(args.func(args))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Public API for other packages: "which of these fingertips are licensed?"
|
|
2
|
+
|
|
3
|
+
touchlab-slip-detect calls check_fingertips() before it runs, so the
|
|
4
|
+
license server decides which connected fingertips run and reports how long
|
|
5
|
+
each license has left. Model decryption still happens in
|
|
6
|
+
slip_model_deployment.license_validator, which re-verifies the same license
|
|
7
|
+
file on its own -- the server is the pre-run check, not the lock.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from . import paths, protocol, transport
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# A fresh Python can take several seconds to start on Windows (e.g. while
|
|
18
|
+
# antivirus scans newly installed files).
|
|
19
|
+
START_TIMEOUT_SEC = 15.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ServerUnavailable(RuntimeError):
|
|
23
|
+
"""The license server couldn't be started or didn't answer."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def startup_failure(lines: int = 8) -> str:
|
|
27
|
+
"""Why the server didn't start: the end of its log, for error messages."""
|
|
28
|
+
try:
|
|
29
|
+
tail = paths.log_path().read_text(errors="replace").strip().splitlines()[-lines:]
|
|
30
|
+
except OSError:
|
|
31
|
+
tail = []
|
|
32
|
+
if not tail:
|
|
33
|
+
return f"the license server did not start (no log at {paths.log_path()})"
|
|
34
|
+
return ("the license server did not start. End of " + str(paths.log_path()) + ":\n "
|
|
35
|
+
+ "\n ".join(tail))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_running() -> bool:
|
|
39
|
+
try:
|
|
40
|
+
with transport.connect(timeout=1.0) as sock:
|
|
41
|
+
return bool(protocol.request(sock, protocol.PING).get("ok"))
|
|
42
|
+
except (OSError, protocol.ProtocolError):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ensure_server_running(timeout_sec: float = START_TIMEOUT_SEC) -> bool:
|
|
47
|
+
"""Start the license server daemon if it isn't already running."""
|
|
48
|
+
if _is_running():
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
# Same interpreter as the caller, so this works in a venv whose bin/
|
|
52
|
+
# isn't on PATH.
|
|
53
|
+
starter = subprocess.Popen(
|
|
54
|
+
[sys.executable, "-m", "touchlab_license_server.cli", "start"],
|
|
55
|
+
stdout=subprocess.DEVNULL,
|
|
56
|
+
stderr=subprocess.DEVNULL,
|
|
57
|
+
# No console window flashing up on Windows.
|
|
58
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
59
|
+
)
|
|
60
|
+
deadline = time.monotonic() + timeout_sec
|
|
61
|
+
while time.monotonic() < deadline:
|
|
62
|
+
if _is_running():
|
|
63
|
+
return True
|
|
64
|
+
# `start` exits 0 once the server answers, 1 if it gave up or the
|
|
65
|
+
# server died -- no point waiting out the full timeout then.
|
|
66
|
+
if starter.poll() not in (None, 0):
|
|
67
|
+
return _is_running()
|
|
68
|
+
time.sleep(0.2)
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_fingertips(serials: list) -> dict:
|
|
73
|
+
"""License status of each fingertip serial, from the license server.
|
|
74
|
+
|
|
75
|
+
Starts the server if needed and syncs first, so a license activated
|
|
76
|
+
moments ago counts straight away. Returns, per serial:
|
|
77
|
+
{"licensed": bool, "reason": "ok" | "not_registered" | "expired" |
|
|
78
|
+
"invalid_signature" | "sn_mismatch" | "bad_expiry_format",
|
|
79
|
+
"expires_at": str | None,
|
|
80
|
+
"days_remaining": int | None} # only when licensed; None = perpetual
|
|
81
|
+
|
|
82
|
+
Raises ServerUnavailable if the server can't be reached.
|
|
83
|
+
"""
|
|
84
|
+
if not ensure_server_running():
|
|
85
|
+
raise ServerUnavailable(startup_failure())
|
|
86
|
+
try:
|
|
87
|
+
with transport.connect(timeout=5.0) as sock:
|
|
88
|
+
protocol.request(sock, protocol.SYNC)
|
|
89
|
+
resp = protocol.request(sock, protocol.CHECK, serials=list(serials))
|
|
90
|
+
except (OSError, protocol.ProtocolError) as exc:
|
|
91
|
+
raise ServerUnavailable(f"license server did not answer: {exc}") from exc
|
|
92
|
+
if not resp.get("ok"):
|
|
93
|
+
raise ServerUnavailable(f"license check failed: {resp.get('error')}")
|
|
94
|
+
return resp["results"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Signature verification for Touchlab license files.
|
|
2
|
+
|
|
3
|
+
Matches slip_model_deployment.license_validator._verify_license exactly —
|
|
4
|
+
same Ed25519 public key, same signed message layout — so this server can
|
|
5
|
+
verify the real license files touchlab-activate writes to
|
|
6
|
+
~/.config/touchlab/licenses/<sn>.json without needing any change to that
|
|
7
|
+
(locked/compiled) module.
|
|
8
|
+
|
|
9
|
+
License dict shape:
|
|
10
|
+
{
|
|
11
|
+
"sn": ..., "customer_id": ..., "product_id": ...,
|
|
12
|
+
"license_salt": "<base64>", "encrypted_model_key": "<base64>",
|
|
13
|
+
"expires_at": "<iso8601 or empty for perpetual>",
|
|
14
|
+
"signature": "<base64 ed25519 sig>",
|
|
15
|
+
}
|
|
16
|
+
The signature covers sn + customer_id + product_id + license_salt +
|
|
17
|
+
encrypted_model_key + expires_at, concatenated as UTF-8 — no separators,
|
|
18
|
+
no re-ordering. This module does not try to be a better/newer scheme;
|
|
19
|
+
it is a read-only, independent re-implementation of the existing check.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
|
|
24
|
+
from cryptography.exceptions import InvalidSignature
|
|
25
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
26
|
+
|
|
27
|
+
TOUCHLAB_PUBLIC_KEY_B64 = "IIAxzS/3IjVnC3bMUcSmeaQNrd8Q1w3QH7TdBJ7vso8="
|
|
28
|
+
|
|
29
|
+
_REQUIRED_FIELDS = (
|
|
30
|
+
"sn",
|
|
31
|
+
"customer_id",
|
|
32
|
+
"product_id",
|
|
33
|
+
"license_salt",
|
|
34
|
+
"encrypted_model_key",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class InvalidLicense(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_public_key() -> Ed25519PublicKey:
|
|
43
|
+
return Ed25519PublicKey.from_public_bytes(base64.b64decode(TOUCHLAB_PUBLIC_KEY_B64))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def signed_message(license: dict) -> bytes:
|
|
47
|
+
expires_at = license.get("expires_at", "")
|
|
48
|
+
return (
|
|
49
|
+
license["sn"]
|
|
50
|
+
+ license["customer_id"]
|
|
51
|
+
+ license["product_id"]
|
|
52
|
+
+ license["license_salt"]
|
|
53
|
+
+ license["encrypted_model_key"]
|
|
54
|
+
+ expires_at
|
|
55
|
+
).encode("utf-8")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def verify_license(license: dict) -> None:
|
|
59
|
+
"""Raise InvalidLicense if the signature doesn't check out. Does not check expiry —
|
|
60
|
+
that's a runtime concern (store.py), not a signature concern."""
|
|
61
|
+
for field in _REQUIRED_FIELDS:
|
|
62
|
+
if field not in license:
|
|
63
|
+
raise InvalidLicense(f"missing field: {field}")
|
|
64
|
+
try:
|
|
65
|
+
signature = base64.b64decode(license["signature"])
|
|
66
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
67
|
+
raise InvalidLicense(f"malformed signature: {exc}") from exc
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
_load_public_key().verify(signature, signed_message(license))
|
|
71
|
+
except InvalidSignature as exc:
|
|
72
|
+
raise InvalidLicense("signature verification failed") from exc
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Ingests license files written by `touchlab-activate` into the server's DB.
|
|
2
|
+
|
|
3
|
+
touchlab-activate (slip_model_deployment.activation_client) is a locked,
|
|
4
|
+
compiled console script — this project doesn't change what it does. It
|
|
5
|
+
still writes ~/.config/touchlab/licenses/<sn>.json exactly as before. This
|
|
6
|
+
module just also reads that directory and keeps the server's DB in sync,
|
|
7
|
+
so the license server becomes an additional, centralized view of the same
|
|
8
|
+
licenses rather than a replacement activation path.
|
|
9
|
+
|
|
10
|
+
Each file is independently signature-verified before being trusted — the
|
|
11
|
+
importer does not assume the filesystem contents are honest.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import crypto, store
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("touchlab_license_server")
|
|
21
|
+
|
|
22
|
+
# Same constant as slip_model_deployment.license_validator._LICENSE_DIR /
|
|
23
|
+
# activation_client._LICENSE_DIR — this is where touchlab-activate writes.
|
|
24
|
+
LICENSE_DIR = Path.home() / ".config" / "touchlab" / "licenses"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def scan_and_import(conn) -> list:
|
|
28
|
+
"""Verify and register every *.json file in LICENSE_DIR. Returns list of sns imported."""
|
|
29
|
+
if not LICENSE_DIR.is_dir():
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
imported = []
|
|
33
|
+
for path in sorted(LICENSE_DIR.glob("*.json")):
|
|
34
|
+
try:
|
|
35
|
+
license = json.loads(path.read_text())
|
|
36
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
37
|
+
log.warning("skipping %s: %s", path, exc)
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
result = store.register(conn, license)
|
|
42
|
+
except crypto.InvalidLicense as exc:
|
|
43
|
+
log.warning("skipping %s: %s", path, exc)
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
imported.append(result["sn"])
|
|
47
|
+
|
|
48
|
+
return imported
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Filesystem locations for the license server: socket/port, database, pidfile.
|
|
2
|
+
|
|
3
|
+
Everything is scoped to the invoking user -- this is a per-user daemon, not a
|
|
4
|
+
system service. Linux uses the XDG dirs; Windows uses %LOCALAPPDATA%.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
WINDOWS = os.name == "nt"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _local_appdata() -> Path:
|
|
14
|
+
return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "touchlab"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def runtime_dir() -> Path:
|
|
18
|
+
if WINDOWS:
|
|
19
|
+
d = _local_appdata() / "run"
|
|
20
|
+
else:
|
|
21
|
+
base = os.environ.get("XDG_RUNTIME_DIR") or f"/tmp/touchlab-{os.getuid()}"
|
|
22
|
+
d = Path(base) / "touchlab"
|
|
23
|
+
d.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
24
|
+
return d
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def data_dir() -> Path:
|
|
28
|
+
if WINDOWS:
|
|
29
|
+
d = _local_appdata() / "license_server"
|
|
30
|
+
else:
|
|
31
|
+
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
32
|
+
d = Path(base) / "touchlab" / "license_server"
|
|
33
|
+
d.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
34
|
+
return d
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def socket_path() -> Path:
|
|
38
|
+
"""Unix socket the server listens on (Linux)."""
|
|
39
|
+
return runtime_dir() / "license.sock"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def port_path() -> Path:
|
|
43
|
+
"""File holding the 127.0.0.1 TCP port the server listens on (Windows)."""
|
|
44
|
+
return runtime_dir() / "license.port"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def db_path() -> Path:
|
|
48
|
+
return data_dir() / "licenses.db"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pid_path() -> Path:
|
|
52
|
+
return runtime_dir() / "server.pid"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def log_path() -> Path:
|
|
56
|
+
return data_dir() / "server.log"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Wire format for the license server IPC channel.
|
|
2
|
+
|
|
3
|
+
Plain length-prefixed JSON over a Unix domain socket — no HTTP stack needed
|
|
4
|
+
for a link that never leaves the local machine. One frame = 4-byte
|
|
5
|
+
big-endian length prefix + UTF-8 JSON body.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import socket
|
|
10
|
+
import struct
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
MAX_FRAME_BYTES = 1 * 1024 * 1024 # signed bundles are small; guards against a wedged peer
|
|
14
|
+
|
|
15
|
+
REGISTER = "REGISTER" # {license} -> {ok, sn, expires_at} | {ok: false, error}
|
|
16
|
+
CHECK = "CHECK" # {serials: [sn, ...]} -> {sn: {licensed, expires_at, days_remaining?, reason}, ...}
|
|
17
|
+
LIST = "LIST" # {} -> {licenses: [{sn, expires_at}, ...]}
|
|
18
|
+
PING = "PING" # {} -> {ok: true}
|
|
19
|
+
SYNC = "SYNC" # {} -> {ok: true, imported: [sn, ...]} — re-scan LICENSE_DIR now
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProtocolError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pack(obj: dict) -> bytes:
|
|
27
|
+
body = json.dumps(obj).encode("utf-8")
|
|
28
|
+
if len(body) > MAX_FRAME_BYTES:
|
|
29
|
+
raise ProtocolError(f"frame too large: {len(body)} bytes")
|
|
30
|
+
return struct.pack(">I", len(body)) + body
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _recv_exact(sock: socket.socket, n: int) -> Optional[bytes]:
|
|
34
|
+
buf = bytearray()
|
|
35
|
+
while len(buf) < n:
|
|
36
|
+
chunk = sock.recv(n - len(buf))
|
|
37
|
+
if not chunk:
|
|
38
|
+
return None
|
|
39
|
+
buf.extend(chunk)
|
|
40
|
+
return bytes(buf)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def recv(sock: socket.socket) -> Optional[dict]:
|
|
44
|
+
"""Blocking read of one frame from a plain socket. None on clean EOF."""
|
|
45
|
+
header = _recv_exact(sock, 4)
|
|
46
|
+
if header is None:
|
|
47
|
+
return None
|
|
48
|
+
(length,) = struct.unpack(">I", header)
|
|
49
|
+
if length > MAX_FRAME_BYTES:
|
|
50
|
+
raise ProtocolError(f"frame too large: {length} bytes")
|
|
51
|
+
body = _recv_exact(sock, length)
|
|
52
|
+
if body is None:
|
|
53
|
+
raise ProtocolError("connection closed mid-frame")
|
|
54
|
+
return json.loads(body.decode("utf-8"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def request(sock: socket.socket, action: str, **fields) -> dict:
|
|
58
|
+
sock.sendall(pack({"action": action, **fields}))
|
|
59
|
+
resp = recv(sock)
|
|
60
|
+
if resp is None:
|
|
61
|
+
raise ProtocolError("server closed connection without responding")
|
|
62
|
+
return resp
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def recv_async(reader) -> Optional[dict]:
|
|
66
|
+
"""Same framing, for asyncio.StreamReader on the server side."""
|
|
67
|
+
header = await reader.readexactly(4)
|
|
68
|
+
(length,) = struct.unpack(">I", header)
|
|
69
|
+
if length > MAX_FRAME_BYTES:
|
|
70
|
+
raise ProtocolError(f"frame too large: {length} bytes")
|
|
71
|
+
body = await reader.readexactly(length)
|
|
72
|
+
return json.loads(body.decode("utf-8"))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Unix-socket daemon: verifies and stores signed licenses, answers CHECK
|
|
2
|
+
queries for connected fingertip serials.
|
|
3
|
+
|
|
4
|
+
Also periodically re-scans importer.LICENSE_DIR so licenses written by the
|
|
5
|
+
locked `touchlab-activate` console script get picked up automatically —
|
|
6
|
+
this server never needs to be in the activation path to stay in sync.
|
|
7
|
+
|
|
8
|
+
One process per user, listening locally only (see transport.py). No network exposure.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
from . import crypto, importer, paths, protocol, store, transport
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger("touchlab_license_server")
|
|
17
|
+
|
|
18
|
+
_IMPORT_INTERVAL_SEC = 5.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LicenseServer:
|
|
22
|
+
def __init__(self, db_path=None, socket_path=None):
|
|
23
|
+
self._db_path = db_path or paths.db_path()
|
|
24
|
+
self._socket_path = socket_path or paths.socket_path()
|
|
25
|
+
self._conn = store.open_store(self._db_path)
|
|
26
|
+
|
|
27
|
+
def _sync(self) -> list:
|
|
28
|
+
imported = importer.scan_and_import(self._conn)
|
|
29
|
+
if imported:
|
|
30
|
+
log.info("synced from %s: %s", importer.LICENSE_DIR, imported)
|
|
31
|
+
return imported
|
|
32
|
+
|
|
33
|
+
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
|
|
34
|
+
try:
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
msg = await protocol.recv_async(reader)
|
|
38
|
+
except asyncio.IncompleteReadError:
|
|
39
|
+
break # clean disconnect
|
|
40
|
+
resp = self._dispatch(msg)
|
|
41
|
+
writer.write(protocol.pack(resp))
|
|
42
|
+
await writer.drain()
|
|
43
|
+
except (protocol.ProtocolError, ConnectionResetError) as exc:
|
|
44
|
+
log.warning("dropping client: %s", exc)
|
|
45
|
+
finally:
|
|
46
|
+
writer.close()
|
|
47
|
+
|
|
48
|
+
def _dispatch(self, msg: dict) -> dict:
|
|
49
|
+
action = msg.get("action")
|
|
50
|
+
try:
|
|
51
|
+
if action == protocol.PING:
|
|
52
|
+
return {"ok": True}
|
|
53
|
+
|
|
54
|
+
if action == protocol.REGISTER:
|
|
55
|
+
result = store.register(self._conn, msg["license"])
|
|
56
|
+
log.info("registered sn=%s expires_at=%s", result["sn"], result["expires_at"])
|
|
57
|
+
return {"ok": True, **result}
|
|
58
|
+
|
|
59
|
+
if action == protocol.CHECK:
|
|
60
|
+
serials = msg.get("serials", [])
|
|
61
|
+
return {"ok": True, "results": store.check(self._conn, serials)}
|
|
62
|
+
|
|
63
|
+
if action == protocol.LIST:
|
|
64
|
+
return {"ok": True, "licenses": store.list_all(self._conn)}
|
|
65
|
+
|
|
66
|
+
if action == protocol.SYNC:
|
|
67
|
+
return {"ok": True, "imported": self._sync()}
|
|
68
|
+
|
|
69
|
+
return {"ok": False, "error": f"unknown action: {action!r}"}
|
|
70
|
+
except crypto.InvalidLicense as exc:
|
|
71
|
+
return {"ok": False, "error": str(exc)}
|
|
72
|
+
except KeyError as exc:
|
|
73
|
+
return {"ok": False, "error": f"missing field: {exc}"}
|
|
74
|
+
|
|
75
|
+
async def _sync_loop(self):
|
|
76
|
+
while True:
|
|
77
|
+
await asyncio.sleep(_IMPORT_INTERVAL_SEC)
|
|
78
|
+
self._sync()
|
|
79
|
+
|
|
80
|
+
async def run(self):
|
|
81
|
+
self._sync() # pick up anything already activated before this server started
|
|
82
|
+
|
|
83
|
+
server = await transport.start_server(self._handle_client, self._socket_path)
|
|
84
|
+
log.info("listening on %s (db=%s)", transport.describe(), self._db_path)
|
|
85
|
+
|
|
86
|
+
async with server:
|
|
87
|
+
sync_task = asyncio.create_task(self._sync_loop())
|
|
88
|
+
try:
|
|
89
|
+
await server.serve_forever()
|
|
90
|
+
finally:
|
|
91
|
+
sync_task.cancel()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main_async(db_path=None, socket_path=None):
|
|
95
|
+
asyncio.run(LicenseServer(db_path, socket_path).run())
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""sqlite-backed license storage.
|
|
2
|
+
|
|
3
|
+
The `expires_at` column exists only for fast lookup and `LIST` output — it
|
|
4
|
+
is never the basis for a licensing decision. Every `CHECK` re-verifies the
|
|
5
|
+
stored license's signature and re-derives expiry from the verified fields,
|
|
6
|
+
so hand-editing a row in the database (e.g. via the sqlite3 CLI) has no
|
|
7
|
+
effect: the edited column is simply not read for the yes/no decision.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import sqlite3
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from . import crypto
|
|
17
|
+
|
|
18
|
+
_SCHEMA = """
|
|
19
|
+
CREATE TABLE IF NOT EXISTS licenses (
|
|
20
|
+
sn TEXT PRIMARY KEY,
|
|
21
|
+
license_json TEXT NOT NULL,
|
|
22
|
+
expires_at TEXT,
|
|
23
|
+
imported_at TEXT NOT NULL
|
|
24
|
+
);
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def open_store(path: Path) -> sqlite3.Connection:
|
|
29
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
30
|
+
conn = sqlite3.connect(str(path))
|
|
31
|
+
conn.execute(_SCHEMA)
|
|
32
|
+
conn.commit()
|
|
33
|
+
try:
|
|
34
|
+
path.chmod(0o600)
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
return conn
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_expiry(expires_at: str) -> datetime:
|
|
41
|
+
return datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def register(conn: sqlite3.Connection, license: dict) -> dict:
|
|
45
|
+
"""Verify a license dict's signature and (re)store it under its serial number.
|
|
46
|
+
|
|
47
|
+
Raises crypto.InvalidLicense on a bad/missing signature. Returns {sn, expires_at}.
|
|
48
|
+
"""
|
|
49
|
+
crypto.verify_license(license)
|
|
50
|
+
sn = license["sn"]
|
|
51
|
+
expires_at = license.get("expires_at")
|
|
52
|
+
|
|
53
|
+
conn.execute(
|
|
54
|
+
"""
|
|
55
|
+
INSERT INTO licenses (sn, license_json, expires_at, imported_at)
|
|
56
|
+
VALUES (?, ?, ?, ?)
|
|
57
|
+
ON CONFLICT(sn) DO UPDATE SET
|
|
58
|
+
license_json = excluded.license_json,
|
|
59
|
+
expires_at = excluded.expires_at,
|
|
60
|
+
imported_at = excluded.imported_at
|
|
61
|
+
""",
|
|
62
|
+
(sn, json.dumps(license), expires_at, datetime.now(timezone.utc).isoformat()),
|
|
63
|
+
)
|
|
64
|
+
conn.commit()
|
|
65
|
+
return {"sn": sn, "expires_at": expires_at}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_one(conn: sqlite3.Connection, sn: str) -> dict:
|
|
69
|
+
row = conn.execute("SELECT license_json FROM licenses WHERE sn = ?", (sn,)).fetchone()
|
|
70
|
+
if row is None:
|
|
71
|
+
return {"licensed": False, "expires_at": None, "reason": "not_registered"}
|
|
72
|
+
|
|
73
|
+
license = json.loads(row[0])
|
|
74
|
+
try:
|
|
75
|
+
crypto.verify_license(license)
|
|
76
|
+
except crypto.InvalidLicense:
|
|
77
|
+
# Stored license no longer verifies (e.g. the row was hand-edited) — dead entry.
|
|
78
|
+
return {"licensed": False, "expires_at": None, "reason": "invalid_signature"}
|
|
79
|
+
|
|
80
|
+
if license.get("sn") != sn:
|
|
81
|
+
return {"licensed": False, "expires_at": None, "reason": "sn_mismatch"}
|
|
82
|
+
|
|
83
|
+
expires_at = license.get("expires_at")
|
|
84
|
+
days_remaining = None # None = perpetual
|
|
85
|
+
if expires_at:
|
|
86
|
+
try:
|
|
87
|
+
remaining = _parse_expiry(expires_at) - datetime.now(timezone.utc)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return {"licensed": False, "expires_at": expires_at, "reason": "bad_expiry_format"}
|
|
90
|
+
if remaining.total_seconds() <= 0:
|
|
91
|
+
return {"licensed": False, "expires_at": expires_at, "reason": "expired"}
|
|
92
|
+
# Round up: a 7-day license is "7 days left" right after activation.
|
|
93
|
+
days_remaining = math.ceil(remaining.total_seconds() / 86400)
|
|
94
|
+
|
|
95
|
+
return {"licensed": True, "expires_at": expires_at, "days_remaining": days_remaining, "reason": "ok"}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check(conn: sqlite3.Connection, serials: list) -> dict:
|
|
99
|
+
return {sn: _check_one(conn, sn) for sn in serials}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def list_all(conn: sqlite3.Connection) -> list:
|
|
103
|
+
rows = conn.execute("SELECT sn FROM licenses ORDER BY sn").fetchall()
|
|
104
|
+
return [{"sn": sn, **_check_one(conn, sn)} for (sn,) in rows]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""How clients reach the license server.
|
|
2
|
+
|
|
3
|
+
Linux: a Unix socket in the user's runtime dir (mode 0600, so only this user
|
|
4
|
+
can open it). Windows has no Unix sockets in Python, so there the server
|
|
5
|
+
listens on a 127.0.0.1 TCP port -- never reachable from the network -- and
|
|
6
|
+
writes the port number to paths.port_path() for clients to read.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
|
|
13
|
+
from . import paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def connect(timeout: float = 2.0) -> socket.socket:
|
|
17
|
+
"""Connected socket to the server. Raises OSError if it isn't running."""
|
|
18
|
+
if paths.WINDOWS:
|
|
19
|
+
try:
|
|
20
|
+
port = int(paths.port_path().read_text().strip())
|
|
21
|
+
except (OSError, ValueError) as exc:
|
|
22
|
+
raise ConnectionRefusedError(f"license server port unknown: {exc}") from exc
|
|
23
|
+
return socket.create_connection(("127.0.0.1", port), timeout=timeout)
|
|
24
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
25
|
+
sock.settimeout(timeout)
|
|
26
|
+
try:
|
|
27
|
+
sock.connect(str(paths.socket_path()))
|
|
28
|
+
except OSError:
|
|
29
|
+
sock.close()
|
|
30
|
+
raise
|
|
31
|
+
return sock
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def start_server(handler, socket_path=None):
|
|
35
|
+
"""Start listening. Returns the asyncio server."""
|
|
36
|
+
if paths.WINDOWS:
|
|
37
|
+
server = await asyncio.start_server(handler, host="127.0.0.1", port=0)
|
|
38
|
+
port = server.sockets[0].getsockname()[1]
|
|
39
|
+
paths.port_path().write_text(str(port))
|
|
40
|
+
return server
|
|
41
|
+
socket_path = socket_path or paths.socket_path()
|
|
42
|
+
if socket_path.exists():
|
|
43
|
+
socket_path.unlink() # left behind by a server that didn't shut down cleanly
|
|
44
|
+
server = await asyncio.start_unix_server(handler, path=str(socket_path))
|
|
45
|
+
os.chmod(socket_path, 0o600)
|
|
46
|
+
return server
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def describe() -> str:
|
|
50
|
+
"""Where the server listens, for status messages."""
|
|
51
|
+
if paths.WINDOWS:
|
|
52
|
+
try:
|
|
53
|
+
return f"127.0.0.1:{paths.port_path().read_text().strip()}"
|
|
54
|
+
except OSError:
|
|
55
|
+
return "127.0.0.1 (port unknown)"
|
|
56
|
+
return f"socket: {paths.socket_path()}"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: touchlab-license-server
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Local license check service for touchlab-slip-model: which fingertips are licensed and how many days are left
|
|
5
|
+
Author-email: Touchlab Limited <abi@touchlab.io>
|
|
6
|
+
License-Expression: LicenseRef-Touchlab-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://touchlab.io
|
|
8
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: cryptography>=41
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# touchlab-license-server
|
|
28
|
+
|
|
29
|
+
The local license check for
|
|
30
|
+
[`touchlab-slip-model`](https://pypi.org/project/touchlab-slip-model/). It
|
|
31
|
+
is installed automatically with that package; you don't need to install it
|
|
32
|
+
yourself.
|
|
33
|
+
|
|
34
|
+
Before each run, `touchlab-slip-detect` asks it which connected fingertips
|
|
35
|
+
are licensed and how many days each license has left. It runs as a small
|
|
36
|
+
background service for your user, starts on its own when needed, and only
|
|
37
|
+
listens on a local socket, never on the network.
|
|
38
|
+
|
|
39
|
+
## Commands
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
touchlab-license-server list # every license, whether it's valid, days left
|
|
43
|
+
touchlab-license-server status # is the service running?
|
|
44
|
+
touchlab-license-server sync # re-read the license folder now
|
|
45
|
+
touchlab-license-server stop # stop the service
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`list` output:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
TL50140B9E... licensed=True expires_at=2026-12-25T10:00:00Z (88 day(s) left)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Every check re-verifies the license's signature, so editing a license file
|
|
55
|
+
can't make it valid.
|
|
56
|
+
|
|
57
|
+
On Linux, to start the service at login instead of on first use:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
touchlab-license-server install-service
|
|
61
|
+
systemctl --user daemon-reload
|
|
62
|
+
systemctl --user enable --now touchlab-license-server
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
It listens on a Unix socket on Linux, and on a `127.0.0.1` port on Windows.
|
|
66
|
+
|
|
67
|
+
Logs: `~/.local/share/touchlab/license_server/server.log` on Linux,
|
|
68
|
+
`%LOCALAPPDATA%\touchlab\license_server\server.log` on Windows.
|
|
69
|
+
|
|
70
|
+
## Support
|
|
71
|
+
|
|
72
|
+
support@touchlab.io
|
|
73
|
+
|
|
74
|
+
This software is proprietary; use is governed by the license agreement in
|
|
75
|
+
`LICENSE`.
|
|
76
|
+
|
|
77
|
+
Copyright (c) 2026 Touchlab Limited. All Rights Reserved.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
touchlab_license_server/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
touchlab_license_server/cli.py,sha256=1YRtQ_KkxLLuCTdwnz6dLEchFCPC2kw0ZpPbbWRvfQ4,6418
|
|
3
|
+
touchlab_license_server/client.py,sha256=GUiuGHBvmCdIczNAv7Ldtw5e9SEanIszLS9TuPMzNo0,3584
|
|
4
|
+
touchlab_license_server/crypto.py,sha256=Eu2xMApnuVi_bgmjtjrwk-klrnPSb9yGT5C5AdhVN2c,2456
|
|
5
|
+
touchlab_license_server/importer.py,sha256=5FtNfjoq7rx643bviThyD6zFFoT4pBCDMuq5M2scgMU,1682
|
|
6
|
+
touchlab_license_server/paths.py,sha256=LSzBKQO8b3tLCmTdyESKCV_d2CYT9NOyidYWvGGsC9A,1476
|
|
7
|
+
touchlab_license_server/protocol.py,sha256=yjy8bRM4GW695rMQhOhu4SLd_1N9ilQFeT72UiGDiUg,2431
|
|
8
|
+
touchlab_license_server/server.py,sha256=WMuYAxtj1ElLv9zzhtZy-gU03ZReVRHtOT2jCoZs9mk,3480
|
|
9
|
+
touchlab_license_server/store.py,sha256=Fw5-PJbq8VWL8N41mtPOCFvzumBoh1dn5MUVUvl9SfI,3629
|
|
10
|
+
touchlab_license_server/transport.py,sha256=1ZyeO_0oYGuCpu14Nw8RCY2xninVz0ueo5gvhbCPSlw,1983
|
|
11
|
+
touchlab_license_server/resources/touchlab-license-server.service,sha256=EWzLpw1GiFu2bHzeyFe0Nr0MWfsXOWvU6zdCmY8tYfQ,213
|
|
12
|
+
touchlab_license_server-1.0.0.dist-info/licenses/LICENSE,sha256=fWsf4oEZ7mpBH5vRWHTI11d2Yl6OZtDONV_6XOOJf8w,4630
|
|
13
|
+
touchlab_license_server-1.0.0.dist-info/METADATA,sha256=aROq1UsIb48nIjDd3zijaWIx_f3ZvuSUawRcC4AMfQY,2720
|
|
14
|
+
touchlab_license_server-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
15
|
+
touchlab_license_server-1.0.0.dist-info/entry_points.txt,sha256=xIucovAc_ewswhOMfA0Mee9jYPswdlQKjf-ioyFhEe0,77
|
|
16
|
+
touchlab_license_server-1.0.0.dist-info/top_level.txt,sha256=Mho6GSH_i3iqphpDZqs9CIE5XXDh6H3YH79WmFOXmso,24
|
|
17
|
+
touchlab_license_server-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
PROPRIETARY SOFTWARE LICENSE AGREEMENT: IMPORTANT : READ CAREFULLY
|
|
2
|
+
By downloading, installing or using Touchlab Limited software products
|
|
3
|
+
contained herein, you indicate your acceptance of and agree to be bound by the terms
|
|
4
|
+
|
|
5
|
+
and conditions of this Proprietary Software License Agreement. These terms and
|
|
6
|
+
conditions are a legal agreement between the Licensee (you) and Licensor (Touchlab Limited).
|
|
7
|
+
|
|
8
|
+
IF YOU DO NOT AGREE TO THESE TERMS AND CONDITIONS, DO NOT
|
|
9
|
+
INSTALL, COPY OR USE THE SOFTWARE.
|
|
10
|
+
|
|
11
|
+
LICENSE. Subject to the provisions contained herein, Touchlab Limited grants to you a non-
|
|
12
|
+
|
|
13
|
+
exclusive, non-transferable license to use the Software contained herein. You are
|
|
14
|
+
authorized to use the SOFTWARE only for your own use.
|
|
15
|
+
|
|
16
|
+
DISCLAIMER OF WARRANTIES: YOU AGREE THAT TOUCHLAB LIMITED HAS MADE NO
|
|
17
|
+
EXPRESS WARRANTIES TO YOU REGARDING THE SOFTWARE AND THAT
|
|
18
|
+
THE SOFTWARE IS BEING PROVIDED TO YOU "AS IS" WITHOUT WARRANTY
|
|
19
|
+
OF ANY KIND. TOUCHLAB LIMITED DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
|
20
|
+
THE SOFTWARE, EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION,
|
|
21
|
+
ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE,
|
|
22
|
+
MERCHANTABILITY, MERCHANTABLE QUALITY OR NONINFRINGEMENT OF
|
|
23
|
+
THIRD PARTY RIGHTS. In the case where a state or jurisdiction does not allow the
|
|
24
|
+
exclusion of implied warranties, the above limitations may not apply.
|
|
25
|
+
|
|
26
|
+
LIMIT OF LIABILITY: IN NO EVENT WILL TOUCHLAB LIMITED BE LIABLE TO YOU FOR
|
|
27
|
+
ANY LOSS OF USE, INTERRUPTION OF BUSINESS, OR ANY DIRECT,
|
|
28
|
+
INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY
|
|
29
|
+
KIND (INCLUDING LOST PROFITS) REGARDLESS OF THE FORM OF ACTION
|
|
30
|
+
WHETHER IN CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT
|
|
31
|
+
PRODUCT LIABILITY OR OTHERWISE, EVEN IF TOUCHLAB LIMITED HAS BEEN ADVISED
|
|
32
|
+
OF THE POSSIBILITY OF SUCH DAMAGES. In the case where a state or jurisdiction
|
|
33
|
+
does not allow the exclusion of incidental or consequential damages, the above
|
|
34
|
+
limitations may not apply.
|
|
35
|
+
|
|
36
|
+
PROTECTION. NOTWITHSTANDING ANY COPYRIGHT NOTICES, THE
|
|
37
|
+
SOFTWARE CONTAINS TRADE SECRETS AND CONFIDENTIAL INFORMATION
|
|
38
|
+
OF TOUCHLAB LIMITED AND ITS SUPPLIERS. THE LICENSEE AGREES NOT TO REVERSE
|
|
39
|
+
COMPILE, REVERSE ENGINEER, DISASSEMBLE, OR MODIFY THE SOFTWARE.
|
|
40
|
+
THE SOFTWARE MAY NOT BE RENTED OR LEASED. THE SOFTWARE MAY
|
|
41
|
+
NOT BE INCORPORATED INTO OR BUNDLED WITH ANY OTHER PRODUCT
|
|
42
|
+
SUCH THAT THE SOFTWARE CEASES TO BE A STAND-ALONE PRODUCT.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
EXPORT PROVISION. Licensee acknowledges that the export of the Software is
|
|
46
|
+
subject to Licensor receiving the necessary licenses or approvals for export from the
|
|
47
|
+
United Kingdom. Licensee agrees that regardless of any disclosure made by Licensee to
|
|
48
|
+
Licensor of an ultimate destination of the SOFTWARE, Licensee will not re-export
|
|
49
|
+
directly or indirectly the SOFTWARE, without first obtaining a license from the United
|
|
50
|
+
Kingdom Government, as required. Licensee shall provide Touchlab Limited with any information,
|
|
51
|
+
certifications or other documents which Licensor may require in connection with the
|
|
52
|
+
export of the SOFTWARE, or any portions or parts thereof, as amended, and the rules and regulations promulgated
|
|
53
|
+
thereunder.
|
|
54
|
+
|
|
55
|
+
SUCCESSORS AND ASSIGNS. The Agreement shall be binding on and inure to the
|
|
56
|
+
benefit of the parties, their successors, permitted assigns and legal representatives.
|
|
57
|
+
Licensee may not assign this Agreement without Licensors prior written consent.
|
|
58
|
+
|
|
59
|
+
TITLE. This Agreement grants Licensee no title or rights of or to ownership in the
|
|
60
|
+
SOFTWARE or documentation or any corrections, improvements or revisions furnished.
|
|
61
|
+
THIS AGREEMENT DOES NOT INCLUDE THE RIGHT TO SUBLICENSE.
|
|
62
|
+
|
|
63
|
+
RELIEF. Licensee acknowledges that any violation of this Agreement by Licensee will
|
|
64
|
+
cause irreparable injury to Licensor, and Licensor shall be entitled to injunctive relief.
|
|
65
|
+
|
|
66
|
+
GENERAL. This Agreement merges all prior written and oral communications and
|
|
67
|
+
defines the entire Agreement of the parties concerning the SOFTWARE. All notices
|
|
68
|
+
required under this Agreement or pertaining to modifications to this Agreement shall be
|
|
69
|
+
in writing, including facsimile copies, however, excluding all variations of Electronic
|
|
70
|
+
Mail, and shall be considered given upon personal delivery of the written notice or within
|
|
71
|
+
five (5) days of mailing, postage prepaid and appropriately addressed. This Agreement
|
|
72
|
+
shall be construed in accordance with laws of the Scotland, United Kingdom
|
|
73
|
+
without regard to its conflicts of laws provisions. The failure by either party
|
|
74
|
+
at any time to enforce its rights under this Agreement will not be construed as a waiver of
|
|
75
|
+
such rights, nor will it in any way affect the validity of this Agreement. No waiver by
|
|
76
|
+
either party will be valid unless given in an appropriate writing signed by both parties.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
touchlab_license_server
|