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.

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,12 @@
1
+ [Unit]
2
+ Description=Touchlab local license server
3
+ After=default.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ ExecStart=touchlab-license-server start --foreground
8
+ Restart=on-failure
9
+ RestartSec=2
10
+
11
+ [Install]
12
+ WantedBy=default.target
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ touchlab-license-server = touchlab_license_server.cli:main
@@ -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