ecadlib 0.3.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.
Files changed (199) hide show
  1. ecadlib/__init__.py +3 -0
  2. ecadlib/__main__.py +5 -0
  3. ecadlib/a365_sync.py +637 -0
  4. ecadlib/agent.py +1407 -0
  5. ecadlib/agent_queue.py +780 -0
  6. ecadlib/agent_watch.py +732 -0
  7. ecadlib/altium365.py +385 -0
  8. ecadlib/altium_dblib_sync.py +530 -0
  9. ecadlib/altium_export.py +209 -0
  10. ecadlib/altium_forms.py +768 -0
  11. ecadlib/altium_lib.py +1089 -0
  12. ecadlib/altium_menu.py +1751 -0
  13. ecadlib/altium_pkg.py +396 -0
  14. ecadlib/altium_project.py +1095 -0
  15. ecadlib/app.py +1458 -0
  16. ecadlib/approvals.py +395 -0
  17. ecadlib/assets/demo_bom.csv +8 -0
  18. ecadlib/assets/kicad_action_plugin.py +607 -0
  19. ecadlib/assets/kicad_reread.py +60 -0
  20. ecadlib/assets/pcblib_template.json.gz +0 -0
  21. ecadlib/bom_inputs.py +339 -0
  22. ecadlib/bom_triage.py +519 -0
  23. ecadlib/cad_model.py +528 -0
  24. ecadlib/cfb.py +171 -0
  25. ecadlib/cli.py +185 -0
  26. ecadlib/clihelp.py +149 -0
  27. ecadlib/cliutil.py +119 -0
  28. ecadlib/completion.py +173 -0
  29. ecadlib/costs.py +276 -0
  30. ecadlib/crosscheck.py +412 -0
  31. ecadlib/datasheet.py +2168 -0
  32. ecadlib/dedupe.py +572 -0
  33. ecadlib/detect.py +170 -0
  34. ecadlib/doctor.py +450 -0
  35. ecadlib/ds_route.py +541 -0
  36. ecadlib/eagle_export.py +1409 -0
  37. ecadlib/easy_setup.py +338 -0
  38. ecadlib/easyeda_convert.py +386 -0
  39. ecadlib/easyeda_pro_export.py +972 -0
  40. ecadlib/eval/__init__.py +0 -0
  41. ecadlib/eval/baselines/kicad-v1.json +404 -0
  42. ecadlib/eval/build_set.py +299 -0
  43. ecadlib/eval/cli.py +119 -0
  44. ecadlib/eval/gate.py +358 -0
  45. ecadlib/eval/golden/demo.txt +379 -0
  46. ecadlib/eval/golden/specs.txt +3120 -0
  47. ecadlib/eval/golden/testpack.txt +463 -0
  48. ecadlib/eval/golden.py +446 -0
  49. ecadlib/eval/replay/kicad-v1.json.gz +0 -0
  50. ecadlib/eval/runner.py +456 -0
  51. ecadlib/eval/score.py +179 -0
  52. ecadlib/eval/sets/kicad-v1.json +630 -0
  53. ecadlib/firstrun.py +400 -0
  54. ecadlib/footprint_adv.py +1006 -0
  55. ecadlib/footprint_bench.py +218 -0
  56. ecadlib/footprint_check.py +562 -0
  57. ecadlib/footprint_gen.py +582 -0
  58. ecadlib/footprint_rules.py +1092 -0
  59. ecadlib/forge.py +267 -0
  60. ecadlib/fp_share.py +95 -0
  61. ecadlib/fp_tidy.py +389 -0
  62. ecadlib/fsutil.py +62 -0
  63. ecadlib/house_style.py +1051 -0
  64. ecadlib/httpcache.py +124 -0
  65. ecadlib/hub.py +1490 -0
  66. ecadlib/hub_copy.py +161 -0
  67. ecadlib/hub_tools.py +79 -0
  68. ecadlib/kicad_dblib.py +257 -0
  69. ecadlib/kicad_export.py +837 -0
  70. ecadlib/kicad_import.py +913 -0
  71. ecadlib/kicad_index.py +160 -0
  72. ecadlib/kicad_menu.py +193 -0
  73. ecadlib/kicad_pcm.py +475 -0
  74. ecadlib/launcher.py +156 -0
  75. ecadlib/librarian.py +963 -0
  76. ecadlib/lifecycle.py +691 -0
  77. ecadlib/llm/__init__.py +276 -0
  78. ecadlib/llm/cli.py +70 -0
  79. ecadlib/llm/openai_compat.py +214 -0
  80. ecadlib/llm/profiles.py +245 -0
  81. ecadlib/llm/rails.py +570 -0
  82. ecadlib/llm/run.py +126 -0
  83. ecadlib/llm/sampling.py +49 -0
  84. ecadlib/mcp_server.py +723 -0
  85. ecadlib/messages.py +419 -0
  86. ecadlib/migrate.py +407 -0
  87. ecadlib/notify.py +329 -0
  88. ecadlib/package_schema.json +1238 -0
  89. ecadlib/package_specs/CP_EIA-3216-18_Kemet-A.json +27 -0
  90. ecadlib/package_specs/C_0402_1005Metric.json +22 -0
  91. ecadlib/package_specs/C_0805_2012Metric.json +22 -0
  92. ecadlib/package_specs/DFN-8-1EP_3x3mm_P0.5mm_EP1.66x2.38mm.json +40 -0
  93. ecadlib/package_specs/D_SMA.json +27 -0
  94. ecadlib/package_specs/D_SMB.json +27 -0
  95. ecadlib/package_specs/D_SOD-123.json +43 -0
  96. ecadlib/package_specs/D_SOD-323.json +43 -0
  97. ecadlib/package_specs/LED_0603_1608Metric.json +23 -0
  98. ecadlib/package_specs/LED_0805_2012Metric.json +23 -0
  99. ecadlib/package_specs/LQFP-48_7x7mm_P0.5mm.json +31 -0
  100. ecadlib/package_specs/L_0805_2012Metric.json +22 -0
  101. ecadlib/package_specs/MSOP-8_3x3mm_P0.65mm.json +31 -0
  102. ecadlib/package_specs/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm.json +39 -0
  103. ecadlib/package_specs/QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm.json +37 -0
  104. ecadlib/package_specs/R_0402_1005Metric.json +22 -0
  105. ecadlib/package_specs/R_0603_1608Metric.json +22 -0
  106. ecadlib/package_specs/R_0805_2012Metric.json +22 -0
  107. ecadlib/package_specs/R_1206_3216Metric.json +22 -0
  108. ecadlib/package_specs/SOIC-16_3.9x9.9mm_P1.27mm.json +32 -0
  109. ecadlib/package_specs/SOIC-8_3.9x4.9mm_P1.27mm.json +32 -0
  110. ecadlib/package_specs/SOT-223-3_TabPin2.json +56 -0
  111. ecadlib/package_specs/SOT-23-5.json +57 -0
  112. ecadlib/package_specs/SOT-23-6.json +62 -0
  113. ecadlib/package_specs/SOT-23.json +48 -0
  114. ecadlib/package_specs/TSSOP-16_4.4x5mm_P0.65mm.json +31 -0
  115. ecadlib/package_specs/VQFN-16-1EP_3x3mm_P0.5mm_EP1.68x1.68mm.json +39 -0
  116. ecadlib/package_specs/advanced/BGA-100_11.0x11.0mm_Layout10x10_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  117. ecadlib/package_specs/advanced/BGA-121_9.0x9.0mm_Layout11x11_P0.8mm_Ball0.4mm_Pad0.35mm_NSMD.json +26 -0
  118. ecadlib/package_specs/advanced/BGA-144_7.0x7.0mm_Layout13x13_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +29 -0
  119. ecadlib/package_specs/advanced/BGA-169_11.0x11.0mm_Layout13x13_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +23 -0
  120. ecadlib/package_specs/advanced/BGA-256_11.0x11.0mm_Layout20x20_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +26 -0
  121. ecadlib/package_specs/advanced/BGA-256_17.0x17.0mm_Layout16x16_P1.0mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  122. ecadlib/package_specs/advanced/BGA-324_15.0x15.0mm_Layout18x18_P0.8mm_Ball0.5mm_Pad0.4mm_NSMD.json +26 -0
  123. ecadlib/package_specs/advanced/BGA-36_3.396x3.466mm_Layout6x6_P0.4mm_Ball0.25mm_Pad0.2mm_NSMD.json +24 -0
  124. ecadlib/package_specs/advanced/BGA-672_27.0x27.0mm_Layout26x26_P1.0mm_Ball0.6mm_Pad0.5mm_NSMD.json +32 -0
  125. ecadlib/package_specs/advanced/BGA-68_5.0x5.0mm_Layout9x9_P0.5mm_Ball0.3mm_Pad0.25mm_NSMD.json +30 -0
  126. ecadlib/package_specs/advanced/BGA-90_8.0x13.0mm_Layout2x3x15_P0.8mm.json +26 -0
  127. ecadlib/package_specs/advanced/DFN-8-1EP_3x3mm_P0.5mm_EP1.65x2.38mm_ThermalVias.json +60 -0
  128. ecadlib/package_specs/advanced/Example_Custom_LPad_Slot.json +24 -0
  129. ecadlib/package_specs/advanced/JST_GH_SM04B-GHS-TB_1x04-1MP_P1.25mm_Horizontal.json +49 -0
  130. ecadlib/package_specs/advanced/JST_PH_B4B-PH-K_1x04_P2.00mm_Vertical.json +33 -0
  131. ecadlib/package_specs/advanced/JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal.json +55 -0
  132. ecadlib/package_specs/advanced/JST_SH_SM04B-SRSS-TB_1x04-1MP_P1.00mm_Horizontal.json +49 -0
  133. ecadlib/package_specs/advanced/JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical.json +34 -0
  134. ecadlib/package_specs/advanced/JST_XH_B4B-XH-A_1x04_P2.50mm_Vertical.json +34 -0
  135. ecadlib/package_specs/advanced/JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical.json +33 -0
  136. ecadlib/package_specs/advanced/LFBGA-144_10x10mm_Layout12x12_P0.8mm.json +23 -0
  137. ecadlib/package_specs/advanced/Molex_KK-254_AE-6410-04A_1x04_P2.54mm_Vertical.json +34 -0
  138. ecadlib/package_specs/advanced/Molex_PicoBlade_53398-0471_1x04-1MP_P1.25mm_Vertical.json +54 -0
  139. ecadlib/package_specs/advanced/PinHeader_1x05_P1.00mm_Vertical_SMD_Pin1Left.json +14 -0
  140. ecadlib/package_specs/advanced/PinHeader_1x05_P1.27mm_Vertical_SMD_Pin1Left.json +14 -0
  141. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical.json +12 -0
  142. ecadlib/package_specs/advanced/PinHeader_1x05_P2.00mm_Vertical_SMD_Pin1Left.json +14 -0
  143. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical.json +12 -0
  144. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Left.json +25 -0
  145. ecadlib/package_specs/advanced/PinHeader_1x05_P2.54mm_Vertical_SMD_Pin1Right.json +14 -0
  146. ecadlib/package_specs/advanced/PinHeader_1x10_P1.00mm_Vertical.json +15 -0
  147. ecadlib/package_specs/advanced/PinHeader_2x05_P1.00mm_Vertical_SMD.json +13 -0
  148. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical.json +18 -0
  149. ecadlib/package_specs/advanced/PinHeader_2x05_P1.27mm_Vertical_SMD.json +24 -0
  150. ecadlib/package_specs/advanced/PinHeader_2x05_P2.00mm_Vertical_SMD.json +13 -0
  151. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical.json +12 -0
  152. ecadlib/package_specs/advanced/PinHeader_2x05_P2.54mm_Vertical_SMD.json +24 -0
  153. ecadlib/package_specs/advanced/PinHeader_2x20_P2.54mm_Vertical.json +12 -0
  154. ecadlib/package_specs/advanced/QFN-16-1EP_3x3mm_P0.5mm_EP1.75x1.75mm_ThermalVias.json +87 -0
  155. ecadlib/package_specs/advanced/UFBGA-132_7x7mm_Layout12x12_P0.5mm.json +43 -0
  156. ecadlib/package_specs/advanced/USB_C_Receptacle_HRO_TYPE-C-31-M-12.json +218 -0
  157. ecadlib/package_specs/standard.json.gz +0 -0
  158. ecadlib/parity.py +1204 -0
  159. ecadlib/parts_export.py +154 -0
  160. ecadlib/partview.py +215 -0
  161. ecadlib/plugins.py +231 -0
  162. ecadlib/providers.py +226 -0
  163. ecadlib/rebuild.py +471 -0
  164. ecadlib/release_check.py +437 -0
  165. ecadlib/reports.py +274 -0
  166. ecadlib/resolve.py +556 -0
  167. ecadlib/review_cli.py +292 -0
  168. ecadlib/rules.py +979 -0
  169. ecadlib/rules_infer.py +1205 -0
  170. ecadlib/sexpr.py +149 -0
  171. ecadlib/sheetsafe.py +65 -0
  172. ecadlib/sim_models.py +1087 -0
  173. ecadlib/sourcing.py +1161 -0
  174. ecadlib/stdparts.py +755 -0
  175. ecadlib/step_gen.py +929 -0
  176. ecadlib/symbol_layout.py +798 -0
  177. ecadlib/team.py +558 -0
  178. ecadlib/team_setup.py +288 -0
  179. ecadlib/term.py +420 -0
  180. ecadlib/term_views.py +64 -0
  181. ecadlib/testpack.py +334 -0
  182. ecadlib/ui/__init__.py +1 -0
  183. ecadlib/ui/__main__.py +3 -0
  184. ecadlib/ui/server.py +1029 -0
  185. ecadlib/ui/static/hairline.js +2 -0
  186. ecadlib/ui/static/index.html +2098 -0
  187. ecadlib/ui/static/setup.html +294 -0
  188. ecadlib/upkeep.py +717 -0
  189. ecadlib/upstream.py +573 -0
  190. ecadlib/userconfig.py +257 -0
  191. ecadlib/visual_check.py +1603 -0
  192. ecadlib/ws_status.py +203 -0
  193. ecadlib-0.3.0.dist-info/METADATA +2245 -0
  194. ecadlib-0.3.0.dist-info/RECORD +199 -0
  195. ecadlib-0.3.0.dist-info/WHEEL +5 -0
  196. ecadlib-0.3.0.dist-info/entry_points.txt +2 -0
  197. ecadlib-0.3.0.dist-info/licenses/LICENSE +21 -0
  198. ecadlib-0.3.0.dist-info/licenses/NOTICE +53 -0
  199. ecadlib-0.3.0.dist-info/top_level.txt +1 -0
ecadlib/team.py ADDED
@@ -0,0 +1,558 @@
1
+ """Team mode for the web UI: users, roles, passwords, sessions and the team audit log. Standard library only.
2
+
3
+ ecadlib team add-user ann --role admin [--name "Ann Lee"] [--email ann@x.com] (asks for the password)
4
+ ecadlib team set-role bob librarian [--sign-as librarian,hardware]
5
+ ecadlib team reset-password bob [--password-stdin]
6
+ ecadlib team remove bob
7
+ ecadlib team list
8
+ ecadlib team setup [--domain D] [--admin NAME] Docker Compose + Caddy (HTTPS), first admin, start (team_setup.py)
9
+ (all take --workspace DIR, default ecadlib_workspace; then: ecadlib ui --team --host 0.0.0.0 --cert c.pem --key k.pem)
10
+
11
+ Files in the workspace (never served by /api/file):
12
+ team_users.json users: role, name, e-mail, scrypt password hash (per-user salt), generation counter
13
+ team.json optional settings (see CONFIG_DEFAULTS): allowed host names, trusted proxy, session lengths, lockout
14
+ team_audit.jsonl one line per write request in team mode: time, user, client address, route, result
15
+
16
+ Roles, lowest first; each includes the ones before it:
17
+ viewer reads everything in the workspace (reports, plans, approval status), downloads files
18
+ librarian uploads BOMs, triage, librarian, resolve, footprints, exports, release-check overrides, Altium 365 dry run
19
+ approver approves / rejects plan items (approvals.py), live Altium 365 push
20
+ admin users, library rules (ecadlib.toml), scanning folders on the server
21
+ The approval role recorded with a sign-off is the user's `sign_as` list (default: the team role); approvals.json
22
+ `users`/`roles` then work as before, keyed by the e-mail (or name) of the signed-in user."""
23
+ import argparse, base64, contextlib, datetime, getpass, hashlib, hmac, ipaddress, json, os, re, secrets, sys, threading, time
24
+ from pathlib import Path
25
+
26
+ ROLES = ("viewer", "librarian", "approver", "admin")
27
+ RANK = {r: i for i, r in enumerate(ROLES)}
28
+ USERS_FILE, CONFIG_FILE, AUDIT_FILE, LOCK_FILE = "team_users.json", "team.json", "team_audit.jsonl", ".team.lock"
29
+ TEAM_FILES = (USERS_FILE, CONFIG_FILE, AUDIT_FILE, LOCK_FILE)
30
+ USERNAME = re.compile(r"^[a-z0-9][a-z0-9._@+-]{0,63}$")
31
+ MIN_PASSWORD = 10
32
+ SCRYPT = {"n": 2 ** 14, "r": 8, "p": 1} # ~16 MiB, ~50 ms per check
33
+ CONFIG_DEFAULTS = {
34
+ "allowed_hosts": [], # extra Host names (any IP literal, localhost and this machine's names always pass)
35
+ "trusted_proxies": [], # proxy IPs whose proxy_user_header is believed (SSO reverse proxy); empty = off
36
+ "proxy_user_header": "X-Forwarded-User",
37
+ "session_hours": 12, # absolute session lifetime
38
+ "idle_minutes": 120, # signed out after this long without a request
39
+ "max_failures": 5, # failed sign-ins per user name within lockout_minutes before it locks
40
+ "lockout_minutes": 15,
41
+ "max_failures_per_ip": 30,
42
+ "secure_cookies": False, # force the Secure cookie flag (behind an HTTPS proxy that does not send X-Forwarded-Proto)
43
+ "request_seconds": 120, # whole request (TLS handshake, headers and body) must arrive within this
44
+ "max_connections_per_ip": 64, # open connections per client address (trusted proxies exempt); 0 = no limit
45
+ "audit_max_mb": 10, # team_audit.jsonl is rotated to .1, .2 ... at this size
46
+ "audit_keep": 5, # rotated audit files kept
47
+ "audit_refusals_per_minute": 20, # unauthenticated refusals logged per address per minute; the rest are counted
48
+ }
49
+
50
+
51
+ def now():
52
+ return datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0).isoformat()
53
+
54
+
55
+ # ---------------------------------------------------------------- passwords
56
+ def hash_password(password):
57
+ if not isinstance(password, str) or len(password) < MIN_PASSWORD:
58
+ raise ValueError(f"password must be at least {MIN_PASSWORD} characters")
59
+ if len(password) > 1024:
60
+ raise ValueError("password is too long")
61
+ salt = secrets.token_bytes(16)
62
+ dk = hashlib.scrypt(password.encode(), salt=salt, dklen=32, **SCRYPT)
63
+ return {"alg": "scrypt", **SCRYPT, "salt": base64.b64encode(salt).decode(), "hash": base64.b64encode(dk).decode()}
64
+
65
+
66
+ _DUMMY = None
67
+
68
+
69
+ def verify_password(password, rec):
70
+ """Constant-time check; a missing record still costs one scrypt so unknown names take as long as known ones."""
71
+ global _DUMMY
72
+ if not isinstance(password, str) or len(password) > 1024:
73
+ password = ""
74
+ if not rec or rec.get("alg") != "scrypt":
75
+ _DUMMY = _DUMMY or hash_password("x" * MIN_PASSWORD)
76
+ rec, ok = _DUMMY, False
77
+ else:
78
+ ok = True
79
+ try:
80
+ dk = hashlib.scrypt(password.encode(), salt=base64.b64decode(rec["salt"]), dklen=32,
81
+ n=int(rec["n"]), r=int(rec["r"]), p=int(rec["p"]))
82
+ except (ValueError, KeyError, TypeError):
83
+ return False
84
+ return hmac.compare_digest(dk, base64.b64decode(rec["hash"])) and ok
85
+
86
+
87
+ # ---------------------------------------------------------------- users file
88
+ @contextlib.contextmanager
89
+ def _file_lock(root):
90
+ """Serialise writers of team_users.json (the server's admin page and the CLI). No-op where fcntl is missing."""
91
+ try:
92
+ import fcntl
93
+ except ImportError: # Windows: single writer assumed
94
+ yield
95
+ return
96
+ with open(Path(root) / LOCK_FILE, "a") as f:
97
+ fcntl.flock(f, fcntl.LOCK_EX)
98
+ try:
99
+ yield
100
+ finally:
101
+ fcntl.flock(f, fcntl.LOCK_UN)
102
+
103
+
104
+ def _write_private(path, text):
105
+ tmp = path.with_name(path.name + f".{os.getpid()}.tmp")
106
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
107
+ with os.fdopen(fd, "w") as f:
108
+ f.write(text)
109
+ os.replace(tmp, path)
110
+
111
+
112
+ def check_username(name):
113
+ name = str(name or "").strip().lower()
114
+ if not USERNAME.match(name):
115
+ raise ValueError("user name: 1-64 characters, letters, digits and . _ @ + - (starting with a letter or digit)")
116
+ return name
117
+
118
+
119
+ def _check_role(role):
120
+ if role not in ROLES:
121
+ raise ValueError(f"role must be one of {', '.join(ROLES)}")
122
+ return role
123
+
124
+
125
+ def _one_line(v, what, n=200):
126
+ v = str(v or "").strip()
127
+ if len(v) > n or any(c in v for c in "\r\n\0<>"):
128
+ raise ValueError(f"{what} must be one line of at most {n} characters without < or >")
129
+ return v
130
+
131
+
132
+ class UserStore:
133
+ """team_users.json, re-read whenever it changes on disk (so `ecadlib team ...` applies to a running server)."""
134
+ def __init__(self, root):
135
+ self.root = Path(root)
136
+ self.path = self.root / USERS_FILE
137
+ self._sig, self._users, self._lock = None, {}, threading.Lock()
138
+
139
+ def users(self):
140
+ with self._lock:
141
+ try:
142
+ st = self.path.stat()
143
+ sig = (st.st_mtime_ns, st.st_size)
144
+ except FileNotFoundError:
145
+ self._sig, self._users = None, {}
146
+ return {}
147
+ if sig != self._sig:
148
+ data = json.loads(self.path.read_text() or "{}")
149
+ self._users, self._sig = dict(data.get("users") or {}), sig
150
+ return self._users
151
+
152
+ def get(self, name):
153
+ try:
154
+ return self.users().get(check_username(name))
155
+ except ValueError:
156
+ return None
157
+
158
+ def find(self, name_or_email):
159
+ """A user by user name or e-mail (what an SSO proxy usually sends)."""
160
+ u = self.get(name_or_email)
161
+ if u:
162
+ return u
163
+ key = str(name_or_email or "").strip().lower()
164
+ return next((u for u in self.users().values() if key and (u.get("email") or "").lower() == key), None)
165
+
166
+ def _edit(self, fn):
167
+ with _file_lock(self.root):
168
+ data = json.loads(self.path.read_text() or "{}") if self.path.exists() else {}
169
+ users = data.setdefault("users", {})
170
+ out = fn(users)
171
+ data["version"] = 1
172
+ _write_private(self.path, json.dumps(data, indent=2, sort_keys=True))
173
+ with self._lock:
174
+ self._sig = None
175
+ return out
176
+
177
+ def add(self, name, role, password=None, full_name="", email="", sign_as=None):
178
+ name, role = check_username(name), _check_role(role)
179
+ full_name, email = _one_line(full_name, "name"), _one_line(email, "e-mail")
180
+ if email and not re.match(r"^[^@\s]+@[^@\s]+$", email):
181
+ raise ValueError("e-mail looks wrong")
182
+ pw = hash_password(password) if password is not None else None
183
+
184
+ def fn(users):
185
+ if name in users:
186
+ raise FileExistsError(f"user {name} already exists")
187
+ users[name] = {"username": name, "role": role, "name": full_name, "email": email, "password": pw,
188
+ "sign_as": _sign_as(sign_as), "gen": secrets.randbits(48), "created": now()}
189
+ # gen starts random: a session of a removed user must not come back when the name is added again
190
+ return dict(users[name])
191
+ return self._edit(fn)
192
+
193
+ def set_role(self, name, role, sign_as=None):
194
+ name, role = check_username(name), _check_role(role)
195
+
196
+ def fn(users):
197
+ u = _need(users, name)
198
+ if u["role"] == "admin" and role != "admin" and _admins(users) == 1:
199
+ raise ValueError("that is the last admin: make someone else admin first")
200
+ u["role"] = role
201
+ if sign_as is not None:
202
+ u["sign_as"] = _sign_as(sign_as)
203
+ return dict(u)
204
+ return self._edit(fn)
205
+
206
+ def reset_password(self, name, password):
207
+ name = check_username(name)
208
+ pw = hash_password(password)
209
+
210
+ def fn(users):
211
+ u = _need(users, name)
212
+ u["password"], u["gen"] = pw, int(u.get("gen") or 1) + 1 # signs out every session of this user
213
+ return dict(u)
214
+ return self._edit(fn)
215
+
216
+ def remove(self, name):
217
+ name = check_username(name)
218
+
219
+ def fn(users):
220
+ u = _need(users, name)
221
+ if u["role"] == "admin" and _admins(users) == 1:
222
+ raise ValueError("that is the last admin and cannot be removed")
223
+ del users[name]
224
+ return u
225
+ return self._edit(fn)
226
+
227
+ def public(self):
228
+ return [{k: u.get(k) for k in ("username", "name", "email", "role", "sign_as", "created")} | {"password": bool(u.get("password"))}
229
+ for u in sorted(self.users().values(), key=lambda u: u["username"])]
230
+
231
+
232
+ def _sign_as(v):
233
+ if v is None:
234
+ return []
235
+ v = [x.strip() for x in (v.split(",") if isinstance(v, str) else v) if isinstance(x, str) and x.strip()]
236
+ for x in v:
237
+ if not re.match(r"^[A-Za-z0-9 ._-]{1,40}$", x):
238
+ raise ValueError(f"sign-as role {x!r}: letters, digits, space . _ - only")
239
+ return v
240
+
241
+
242
+ def _need(users, name):
243
+ if name not in users:
244
+ raise KeyError(f"no user {name}")
245
+ return users[name]
246
+
247
+
248
+ def _admins(users):
249
+ return sum(u.get("role") == "admin" for u in users.values())
250
+
251
+
252
+ def identity(user):
253
+ """How a team user is recorded in approvals: 'Name <email>' (approvals.user_key then keys on the e-mail)."""
254
+ name = user.get("name") or user["username"]
255
+ return f"{name} <{user['email']}>" if user.get("email") else name
256
+
257
+
258
+ def sign_roles(user):
259
+ return list(user.get("sign_as") or []) or [user["role"]]
260
+
261
+
262
+ # ---------------------------------------------------------------- settings
263
+ def load_config(root, overrides=None):
264
+ f = Path(root) / CONFIG_FILE
265
+ raw = json.loads(f.read_text() or "{}") if f.exists() else {}
266
+ bad = set(raw) - set(CONFIG_DEFAULTS)
267
+ if bad:
268
+ raise ValueError(f"{f}: unknown keys {sorted(bad)}; known: {sorted(CONFIG_DEFAULTS)}")
269
+ cfg = {**CONFIG_DEFAULTS, **raw}
270
+ for k, v in (overrides or {}).items():
271
+ if v not in (None, [], ()):
272
+ cfg[k] = list(cfg[k]) + list(v) if isinstance(CONFIG_DEFAULTS[k], list) else v
273
+ for ip in cfg["trusted_proxies"]:
274
+ ipaddress.ip_address(ip) # raises ValueError on a typo
275
+ cfg["allowed_hosts"] = [h.lower() for h in cfg["allowed_hosts"]]
276
+ return cfg
277
+
278
+
279
+ # ---------------------------------------------------------------- sessions and sign-in throttling
280
+ class Sessions:
281
+ """In memory: a restart signs everyone out. token -> {user, gen, csrf, created, seen}."""
282
+ def __init__(self, hours=12, idle_minutes=120):
283
+ self.max_age, self.idle = hours * 3600, idle_minutes * 60
284
+ self.s, self.lock = {}, threading.Lock()
285
+
286
+ def create(self, username, gen):
287
+ tok = secrets.token_urlsafe(32)
288
+ t = time.time()
289
+ with self.lock:
290
+ self.s = {k: v for k, v in self.s.items() if not self._expired(v, t)}
291
+ self.s[tok] = {"user": username, "gen": gen, "csrf": secrets.token_urlsafe(32), "created": t, "seen": t}
292
+ return tok, self.s[tok]
293
+
294
+ def _expired(self, v, t):
295
+ return t - v["created"] > self.max_age or t - v["seen"] > self.idle
296
+
297
+ def get(self, tok):
298
+ if not tok:
299
+ return None
300
+ t = time.time()
301
+ with self.lock:
302
+ v = self.s.get(tok)
303
+ if v and self._expired(v, t):
304
+ del self.s[tok]
305
+ return None
306
+ if v:
307
+ v["seen"] = t
308
+ return v
309
+
310
+ def drop(self, tok):
311
+ with self.lock:
312
+ self.s.pop(tok, None)
313
+
314
+ def remaining(self, v):
315
+ return max(0, int(v["created"] + self.max_age - time.time()))
316
+
317
+
318
+ class Throttle:
319
+ """Failed sign-ins per user name and per client address. Unknown names count too (no user enumeration)."""
320
+ def __init__(self, max_failures=5, lockout_minutes=15, max_per_ip=30):
321
+ # at least one try: 0 in team.json used to index an empty list (every sign-in a 500), and is not "off"
322
+ self.max, self.window, self.max_ip = max(1, int(max_failures)), lockout_minutes * 60, max(1, int(max_per_ip))
323
+ self.by_user, self.by_ip, self.lock = {}, {}, threading.Lock()
324
+
325
+ def _recent(self, d, k, t):
326
+ d[k] = [x for x in d.get(k, []) if t - x < self.window]
327
+ return d[k]
328
+
329
+ def _wait(self, user, ip, t):
330
+ u, i = self._recent(self.by_user, user, t), self._recent(self.by_ip, ip, t)
331
+ waits = [int(u[-self.max] + self.window - t) + 1] if len(u) >= self.max else []
332
+ waits += [int(i[-self.max_ip] + self.window - t) + 1] if len(i) >= self.max_ip else []
333
+ return max(waits, default=0)
334
+
335
+ def blocked(self, user, ip):
336
+ """Seconds until this user name / address may try again, or 0."""
337
+ with self.lock:
338
+ return self._wait(user, ip, time.time())
339
+
340
+ def attempt(self, user, ip):
341
+ """Check and count one password try in one step: (seconds to wait, None) or (0, ticket). The try counts as a
342
+ failure until ok(user, ip, ticket), so parallel requests cannot all pass the check before any failure is
343
+ recorded (that let N concurrent guesses through instead of max_failures)."""
344
+ t = time.time()
345
+ with self.lock:
346
+ wait = self._wait(user, ip, t)
347
+ if wait:
348
+ return wait, None
349
+ self.by_user[user].append(t)
350
+ self.by_ip[ip].append(t)
351
+ return 0, t
352
+
353
+ def fail(self, user, ip):
354
+ t = time.time()
355
+ with self.lock:
356
+ self._recent(self.by_user, user, t).append(t)
357
+ self._recent(self.by_ip, ip, t).append(t)
358
+
359
+ def ok(self, user, ip=None, ticket=None):
360
+ """A right password: forget this user's failures (and the try counted by attempt())."""
361
+ with self.lock:
362
+ self.by_user.pop(user, None)
363
+ if ticket is not None:
364
+ with contextlib.suppress(ValueError):
365
+ self.by_ip.get(ip, []).remove(ticket)
366
+
367
+ def unlock(self, user):
368
+ self.ok(user)
369
+
370
+
371
+ class Team:
372
+ """Everything the web server needs for team mode, for one workspace."""
373
+ def __init__(self, root, overrides=None, tls=False):
374
+ self.root = Path(root)
375
+ self.cfg = load_config(root, overrides)
376
+ self.store = UserStore(root)
377
+ self.sessions = Sessions(self.cfg["session_hours"], self.cfg["idle_minutes"])
378
+ self.throttle = Throttle(self.cfg["max_failures"], self.cfg["lockout_minutes"], self.cfg["max_failures_per_ip"])
379
+ self.secret = secrets.token_bytes(32)
380
+ self.tls = tls
381
+ self.refusals = RefusalLog(self.cfg["audit_refusals_per_minute"])
382
+
383
+ def audit(self, entry):
384
+ """One team audit line; unauthenticated refusals are rate-limited per address (RefusalLog)."""
385
+ if entry.get("user") is None and entry.get("status", 0) >= 400:
386
+ extra = self.refusals.admit(entry.get("ip"))
387
+ if extra is None:
388
+ return
389
+ entry = {**entry, **extra}
390
+ audit(self.root, entry, int(self.cfg["audit_max_mb"] * 1024 * 1024), int(self.cfg["audit_keep"]))
391
+
392
+ def has_admin(self):
393
+ return any(u.get("role") == "admin" for u in self.store.users().values())
394
+
395
+ def proxy_csrf(self, username):
396
+ return hmac.new(self.secret, b"proxy\0" + username.encode(), hashlib.sha256).hexdigest()
397
+
398
+ def host_ok(self, host_header):
399
+ """DNS rebinding guard: the Host must be an IP literal, localhost, this machine's name or an allowed name."""
400
+ import socket
401
+ h = str(host_header or "").strip().lower()
402
+ if not h:
403
+ return False
404
+ name = h[1:h.index("]")] if h.startswith("[") and "]" in h else h.rsplit(":", 1)[0] if h.count(":") == 1 else h
405
+ try:
406
+ ipaddress.ip_address(name)
407
+ return True
408
+ except ValueError:
409
+ pass
410
+ names = {"localhost", socket.gethostname().lower(), socket.getfqdn().lower(), *self.cfg["allowed_hosts"]}
411
+ return name in names
412
+
413
+
414
+ _audit_lock = threading.Lock()
415
+ AUDIT_MAX_BYTES, AUDIT_KEEP = CONFIG_DEFAULTS["audit_max_mb"] * 1024 * 1024, CONFIG_DEFAULTS["audit_keep"]
416
+
417
+
418
+ def _audit_files(root, keep):
419
+ f = Path(root) / AUDIT_FILE
420
+ return [f] + [f.with_name(f"{AUDIT_FILE}.{i}") for i in range(1, keep + 1)]
421
+
422
+
423
+ def audit(root, entry, max_bytes=AUDIT_MAX_BYTES, keep=AUDIT_KEEP):
424
+ """Append one line; at max_bytes the file is rotated (team_audit.jsonl -> .1 -> .2 ..., `keep` old files)."""
425
+ line = json.dumps({"ts": now(), **entry}, sort_keys=True) + "\n"
426
+ with _audit_lock:
427
+ files = _audit_files(root, keep)
428
+ with contextlib.suppress(FileNotFoundError):
429
+ if keep > 0 and files[0].stat().st_size + len(line) > max_bytes:
430
+ for src, dst in reversed(list(zip(files, files[1:]))):
431
+ if src.exists():
432
+ os.replace(src, dst)
433
+ with open(files[0], "a") as f:
434
+ f.write(line)
435
+
436
+
437
+ def _tail(path, n, block=65536):
438
+ """The last n lines of a file, reading backwards from the end (never the whole file)."""
439
+ with open(path, "rb") as f:
440
+ f.seek(0, os.SEEK_END)
441
+ pos, data = f.tell(), b""
442
+ while pos > 0 and data.count(b"\n") <= n:
443
+ step = min(block, pos)
444
+ pos -= step
445
+ f.seek(pos)
446
+ data = f.read(step) + data
447
+ lines = data.splitlines()
448
+ return lines[-n:] if pos == 0 else lines[1:][-n:] # the first line may be cut when we stopped early
449
+
450
+
451
+ def read_audit(root, n=200, keep=AUDIT_KEEP):
452
+ """The newest n entries, newest first, from team_audit.jsonl and then its rotated files."""
453
+ out = []
454
+ for f in _audit_files(root, keep):
455
+ if len(out) >= n:
456
+ break
457
+ if not f.exists():
458
+ continue
459
+ for l in reversed(_tail(f, n - len(out))):
460
+ with contextlib.suppress(json.JSONDecodeError, UnicodeDecodeError):
461
+ out.append(json.loads(l))
462
+ return out[:n]
463
+
464
+
465
+ class RefusalLog:
466
+ """Unauthenticated refusals (401/403/429 before sign-in) cost an anonymous client nothing, so each address gets
467
+ `per_minute` audit lines per minute; the rest are counted and reported as "suppressed" on its next line."""
468
+ def __init__(self, per_minute=20):
469
+ self.per_minute, self.seen, self.lock = per_minute, {}, threading.Lock()
470
+
471
+ def admit(self, ip):
472
+ """None: drop this line. Else a dict to merge into it ({} or {"suppressed": n})."""
473
+ if self.per_minute <= 0:
474
+ return {}
475
+ t = time.time()
476
+ with self.lock:
477
+ if len(self.seen) > 10000: # forget quiet addresses
478
+ self.seen = {k: v for k, v in self.seen.items() if t - v[0] < 60}
479
+ start, count, dropped = self.seen.get(ip, (t, 0, 0))
480
+ if t - start >= 60:
481
+ start, count = t, 0
482
+ if count >= self.per_minute:
483
+ self.seen[ip] = (start, count, dropped + 1)
484
+ return None
485
+ self.seen[ip] = (start, count + 1, 0)
486
+ return {"suppressed": dropped} if dropped else {}
487
+
488
+
489
+ # ---------------------------------------------------------------- CLI
490
+ def _read_password(a, prompt="password"):
491
+ if a.password_stdin:
492
+ return sys.stdin.readline().rstrip("\r\n")
493
+ p1 = getpass.getpass(f"{prompt} (at least {MIN_PASSWORD} characters): ")
494
+ if p1 != getpass.getpass("again: "):
495
+ sys.exit("passwords differ")
496
+ return p1
497
+
498
+
499
+ def main(argv=None):
500
+ argv = sys.argv[1:] if argv is None else list(argv)
501
+ if argv[:1] == ["setup"]: # Docker Compose + Caddy for the whole team (team_setup.py)
502
+ from . import team_setup
503
+ return team_setup.main(argv[1:])
504
+ ap = argparse.ArgumentParser(prog="ecadlib team", description="manage web UI team users (ecadlib ui --team)",
505
+ epilog="ecadlib team setup -h: run the team server with Docker and HTTPS in one command")
506
+ ap.add_argument("--workspace", default="ecadlib_workspace")
507
+ sub = ap.add_subparsers(dest="cmd", required=True)
508
+ p = sub.add_parser("add-user", help="add a user (the first one must be an admin)")
509
+ p.add_argument("user")
510
+ p.add_argument("--role", required=True, choices=ROLES)
511
+ p.add_argument("--name", default="", help="full name, recorded with approvals")
512
+ p.add_argument("--email", default="", help="e-mail, recorded with approvals and matched by approvals.json users")
513
+ p.add_argument("--sign-as", help="comma list of approval roles this user signs as (default: the team role)")
514
+ p.add_argument("--password-stdin", action="store_true", help="read the password from one line of stdin")
515
+ p.add_argument("--no-password", action="store_true", help="sign-in only through the trusted SSO proxy header")
516
+ p = sub.add_parser("set-role", help="change a user's role")
517
+ p.add_argument("user")
518
+ p.add_argument("role", choices=ROLES)
519
+ p.add_argument("--sign-as", help="comma list of approval roles (empty string: the team role)")
520
+ p = sub.add_parser("reset-password", help="set a new password; signs the user out everywhere")
521
+ p.add_argument("user")
522
+ p.add_argument("--password-stdin", action="store_true")
523
+ p = sub.add_parser("remove", help="remove a user")
524
+ p.add_argument("user")
525
+ sub.add_parser("list", help="list users")
526
+ a = ap.parse_args(argv)
527
+ root = Path(a.workspace).resolve()
528
+ root.mkdir(parents=True, exist_ok=True)
529
+ st = UserStore(root)
530
+ try:
531
+ if a.cmd == "add-user":
532
+ if not st.users() and a.role != "admin":
533
+ sys.exit("the first user must be an admin (--role admin)")
534
+ pw = None if a.no_password else _read_password(a)
535
+ u = st.add(a.user, a.role, pw, a.name, a.email, a.sign_as)
536
+ print(f"added {u['username']} as {u['role']} -> {st.path}")
537
+ elif a.cmd == "set-role":
538
+ u = st.set_role(a.user, a.role, a.sign_as)
539
+ print(f"{u['username']} is now {u['role']}" + (f", signs as {', '.join(u['sign_as'])}" if u.get("sign_as") else ""))
540
+ elif a.cmd == "reset-password":
541
+ st.reset_password(a.user, _read_password(a, "new password"))
542
+ print(f"password reset for {check_username(a.user)}; their sessions end on their next request")
543
+ elif a.cmd == "remove":
544
+ st.remove(a.user)
545
+ print(f"removed {check_username(a.user)}")
546
+ else:
547
+ for u in st.public():
548
+ print(f" {u['username']:<20} {u['role']:<10} {identity(u) if u.get('name') or u.get('email') else '':<36}"
549
+ + ("" if u["password"] else " (SSO proxy only)"))
550
+ if not st.users():
551
+ print(f"no users yet: ecadlib team --workspace {a.workspace} add-user NAME --role admin")
552
+ except (ValueError, KeyError, FileExistsError) as e:
553
+ sys.exit(f"ecadlib team: {e.args[0] if e.args else e}")
554
+ return 0
555
+
556
+
557
+ if __name__ == "__main__":
558
+ sys.exit(main())