bizteamai-smcp-biz 1.13.1__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.
- bizteamai_smcp_biz-1.13.1.dist-info/METADATA +119 -0
- bizteamai_smcp_biz-1.13.1.dist-info/RECORD +21 -0
- bizteamai_smcp_biz-1.13.1.dist-info/WHEEL +5 -0
- bizteamai_smcp_biz-1.13.1.dist-info/entry_points.txt +5 -0
- bizteamai_smcp_biz-1.13.1.dist-info/top_level.txt +1 -0
- smcp/__init__.py +53 -0
- smcp/allowlist.py +169 -0
- smcp/app_wrapper.py +216 -0
- smcp/cli/__init__.py +3 -0
- smcp/cli/approve.py +261 -0
- smcp/cli/gen_key.py +73 -0
- smcp/cli/mkcert.py +327 -0
- smcp/cli/revoke.py +76 -0
- smcp/confirm.py +262 -0
- smcp/cpu.py +67 -0
- smcp/decorators.py +97 -0
- smcp/enforce.py +132 -0
- smcp/filters.py +176 -0
- smcp/license.py +232 -0
- smcp/logchain.py +270 -0
- smcp/tls.py +160 -0
smcp/cli/revoke.py
ADDED
@@ -0,0 +1,76 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
License key revocation utility for SMCP Business Edition.
|
4
|
+
"""
|
5
|
+
import sys
|
6
|
+
import hmac
|
7
|
+
import hashlib
|
8
|
+
import argparse
|
9
|
+
import json
|
10
|
+
import os
|
11
|
+
from datetime import datetime
|
12
|
+
|
13
|
+
def revoke_license_key(license_key: str, reason: str = "") -> str:
|
14
|
+
"""Revoke a license key by adding its hash to the revocation list."""
|
15
|
+
# Calculate key hash
|
16
|
+
key_hash = hashlib.sha256(license_key.encode()).hexdigest()
|
17
|
+
|
18
|
+
# Create revocation entry
|
19
|
+
revocation_entry = {
|
20
|
+
"hash": key_hash,
|
21
|
+
"revoked_at": datetime.utcnow().isoformat() + "Z",
|
22
|
+
"reason": reason
|
23
|
+
}
|
24
|
+
|
25
|
+
return key_hash, revocation_entry
|
26
|
+
|
27
|
+
def main():
|
28
|
+
parser = argparse.ArgumentParser(description='Revoke SMCP Business Edition license keys')
|
29
|
+
parser.add_argument('--key', '-k', required=True, help='License key to revoke')
|
30
|
+
parser.add_argument('--reason', '-r', help='Reason for revocation')
|
31
|
+
parser.add_argument('--revocation-db', '-d',
|
32
|
+
default='/var/lib/bizteam/revocation.db',
|
33
|
+
help='Revocation database file')
|
34
|
+
parser.add_argument('--output-json', '-o', help='Output revocation list as JSON')
|
35
|
+
|
36
|
+
args = parser.parse_args()
|
37
|
+
|
38
|
+
try:
|
39
|
+
key_hash, revocation_entry = revoke_license_key(args.key, args.reason or "")
|
40
|
+
|
41
|
+
# Load existing revocation database
|
42
|
+
revocation_list = {}
|
43
|
+
if os.path.exists(args.revocation_db):
|
44
|
+
try:
|
45
|
+
with open(args.revocation_db, 'r') as f:
|
46
|
+
revocation_list = json.load(f)
|
47
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
48
|
+
revocation_list = {}
|
49
|
+
|
50
|
+
# Add new revocation
|
51
|
+
revocation_list[key_hash] = revocation_entry
|
52
|
+
|
53
|
+
# Save updated database
|
54
|
+
os.makedirs(os.path.dirname(args.revocation_db), exist_ok=True)
|
55
|
+
with open(args.revocation_db, 'w') as f:
|
56
|
+
json.dump(revocation_list, f, indent=2)
|
57
|
+
|
58
|
+
print(f"License key revoked: {key_hash[:16]}...")
|
59
|
+
print(f"Revocation database updated: {args.revocation_db}")
|
60
|
+
|
61
|
+
# Output JSON list if requested
|
62
|
+
if args.output_json:
|
63
|
+
json_output = {
|
64
|
+
"revoked": list(revocation_list.keys()),
|
65
|
+
"updated_at": datetime.utcnow().isoformat() + "Z"
|
66
|
+
}
|
67
|
+
with open(args.output_json, 'w') as f:
|
68
|
+
json.dump(json_output, f, indent=2)
|
69
|
+
print(f"JSON revocation list written to: {args.output_json}")
|
70
|
+
|
71
|
+
except Exception as e:
|
72
|
+
print(f"Error revoking license key: {e}", file=sys.stderr)
|
73
|
+
sys.exit(1)
|
74
|
+
|
75
|
+
if __name__ == '__main__':
|
76
|
+
main()
|
smcp/confirm.py
ADDED
@@ -0,0 +1,262 @@
|
|
1
|
+
"""
|
2
|
+
Destructive action confirmation queue and approval system.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import json
|
6
|
+
import time
|
7
|
+
import uuid
|
8
|
+
from pathlib import Path
|
9
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
10
|
+
|
11
|
+
|
12
|
+
class PendingApproval(Exception):
|
13
|
+
"""Raised when an action is queued for approval."""
|
14
|
+
pass
|
15
|
+
|
16
|
+
|
17
|
+
class ActionQueue:
|
18
|
+
"""Manages queued actions awaiting approval."""
|
19
|
+
|
20
|
+
def __init__(self, queue_file: Optional[str] = None):
|
21
|
+
"""
|
22
|
+
Initialize the action queue.
|
23
|
+
|
24
|
+
Args:
|
25
|
+
queue_file: Path to the queue file (defaults to temp file)
|
26
|
+
"""
|
27
|
+
self.queue_file = Path(queue_file or "/tmp/smcp_queue.json")
|
28
|
+
self._ensure_queue_file()
|
29
|
+
|
30
|
+
def _ensure_queue_file(self) -> None:
|
31
|
+
"""Ensure the queue file exists."""
|
32
|
+
if not self.queue_file.exists():
|
33
|
+
self.queue_file.parent.mkdir(parents=True, exist_ok=True)
|
34
|
+
self._save_queue([])
|
35
|
+
|
36
|
+
def _load_queue(self) -> List[Dict[str, Any]]:
|
37
|
+
"""Load the current queue from disk."""
|
38
|
+
try:
|
39
|
+
with open(self.queue_file, 'r') as f:
|
40
|
+
return json.load(f)
|
41
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
42
|
+
return []
|
43
|
+
|
44
|
+
def _save_queue(self, queue: List[Dict[str, Any]]) -> None:
|
45
|
+
"""Save the queue to disk."""
|
46
|
+
with open(self.queue_file, 'w') as f:
|
47
|
+
json.dump(queue, f, indent=2, default=str)
|
48
|
+
|
49
|
+
def add_action(self, action_id: str, function: Callable, args: Tuple, kwargs: Dict[str, Any]) -> None:
|
50
|
+
"""
|
51
|
+
Add an action to the approval queue.
|
52
|
+
|
53
|
+
Args:
|
54
|
+
action_id: Unique identifier for the action
|
55
|
+
function: Function to be executed
|
56
|
+
args: Function arguments
|
57
|
+
kwargs: Function keyword arguments
|
58
|
+
"""
|
59
|
+
queue = self._load_queue()
|
60
|
+
|
61
|
+
action = {
|
62
|
+
"id": action_id,
|
63
|
+
"function_name": function.__name__,
|
64
|
+
"module": function.__module__,
|
65
|
+
"args": args,
|
66
|
+
"kwargs": kwargs,
|
67
|
+
"timestamp": time.time(),
|
68
|
+
"status": "pending"
|
69
|
+
}
|
70
|
+
|
71
|
+
queue.append(action)
|
72
|
+
self._save_queue(queue)
|
73
|
+
|
74
|
+
def get_pending_actions(self) -> List[Dict[str, Any]]:
|
75
|
+
"""Get all pending actions."""
|
76
|
+
queue = self._load_queue()
|
77
|
+
return [action for action in queue if action["status"] == "pending"]
|
78
|
+
|
79
|
+
def approve_action(self, action_id: str) -> Optional[Dict[str, Any]]:
|
80
|
+
"""
|
81
|
+
Approve an action for execution.
|
82
|
+
|
83
|
+
Args:
|
84
|
+
action_id: ID of the action to approve
|
85
|
+
|
86
|
+
Returns:
|
87
|
+
The approved action data, or None if not found
|
88
|
+
"""
|
89
|
+
queue = self._load_queue()
|
90
|
+
|
91
|
+
for action in queue:
|
92
|
+
if action["id"] == action_id and action["status"] == "pending":
|
93
|
+
action["status"] = "approved"
|
94
|
+
action["approved_at"] = time.time()
|
95
|
+
self._save_queue(queue)
|
96
|
+
return action
|
97
|
+
|
98
|
+
return None
|
99
|
+
|
100
|
+
def reject_action(self, action_id: str) -> bool:
|
101
|
+
"""
|
102
|
+
Reject an action.
|
103
|
+
|
104
|
+
Args:
|
105
|
+
action_id: ID of the action to reject
|
106
|
+
|
107
|
+
Returns:
|
108
|
+
True if the action was found and rejected
|
109
|
+
"""
|
110
|
+
queue = self._load_queue()
|
111
|
+
|
112
|
+
for action in queue:
|
113
|
+
if action["id"] == action_id and action["status"] == "pending":
|
114
|
+
action["status"] = "rejected"
|
115
|
+
action["rejected_at"] = time.time()
|
116
|
+
self._save_queue(queue)
|
117
|
+
return True
|
118
|
+
|
119
|
+
return False
|
120
|
+
|
121
|
+
def cleanup_old_actions(self, max_age_hours: int = 24) -> int:
|
122
|
+
"""
|
123
|
+
Remove old actions from the queue.
|
124
|
+
|
125
|
+
Args:
|
126
|
+
max_age_hours: Maximum age in hours before removal
|
127
|
+
|
128
|
+
Returns:
|
129
|
+
Number of actions removed
|
130
|
+
"""
|
131
|
+
queue = self._load_queue()
|
132
|
+
cutoff_time = time.time() - (max_age_hours * 3600)
|
133
|
+
|
134
|
+
old_count = len(queue)
|
135
|
+
queue = [action for action in queue if action["timestamp"] > cutoff_time]
|
136
|
+
new_count = len(queue)
|
137
|
+
|
138
|
+
if old_count != new_count:
|
139
|
+
self._save_queue(queue)
|
140
|
+
|
141
|
+
return old_count - new_count
|
142
|
+
|
143
|
+
|
144
|
+
# Global action queue instance
|
145
|
+
_action_queue = None
|
146
|
+
|
147
|
+
|
148
|
+
def get_action_queue(cfg: Dict[str, Any]) -> ActionQueue:
|
149
|
+
"""Get or create the global action queue."""
|
150
|
+
global _action_queue
|
151
|
+
if _action_queue is None:
|
152
|
+
queue_file = cfg.get("QUEUE_FILE")
|
153
|
+
_action_queue = ActionQueue(queue_file)
|
154
|
+
return _action_queue
|
155
|
+
|
156
|
+
|
157
|
+
def maybe_queue(function: Callable, args: Tuple, kwargs: Dict[str, Any], cfg: Dict[str, Any]) -> bool:
|
158
|
+
"""
|
159
|
+
Queue an action for approval if confirmation is required.
|
160
|
+
|
161
|
+
Args:
|
162
|
+
function: Function to potentially queue
|
163
|
+
args: Function arguments
|
164
|
+
kwargs: Function keyword arguments
|
165
|
+
cfg: Configuration dictionary
|
166
|
+
|
167
|
+
Returns:
|
168
|
+
True if the action was queued (requiring approval)
|
169
|
+
False if the action should proceed immediately
|
170
|
+
"""
|
171
|
+
# Check if confirmation is enabled globally
|
172
|
+
if not cfg.get("CONFIRMATION_ENABLED", True):
|
173
|
+
return False
|
174
|
+
|
175
|
+
# Generate unique action ID
|
176
|
+
action_id = str(uuid.uuid4())
|
177
|
+
|
178
|
+
# Add to queue
|
179
|
+
queue = get_action_queue(cfg)
|
180
|
+
queue.add_action(action_id, function, args, kwargs)
|
181
|
+
|
182
|
+
# Store action ID for potential approval
|
183
|
+
cfg["_last_action_id"] = action_id
|
184
|
+
|
185
|
+
return True
|
186
|
+
|
187
|
+
|
188
|
+
def execute_approved_action(action_id: str, cfg: Dict[str, Any]) -> Any:
|
189
|
+
"""
|
190
|
+
Execute a previously approved action.
|
191
|
+
|
192
|
+
Args:
|
193
|
+
action_id: ID of the action to execute
|
194
|
+
cfg: Configuration dictionary
|
195
|
+
|
196
|
+
Returns:
|
197
|
+
Result of the executed function
|
198
|
+
|
199
|
+
Raises:
|
200
|
+
ValueError: If the action is not found or not approved
|
201
|
+
"""
|
202
|
+
queue = get_action_queue(cfg)
|
203
|
+
action_queue_data = queue._load_queue()
|
204
|
+
|
205
|
+
# Find the action
|
206
|
+
action = None
|
207
|
+
for a in action_queue_data:
|
208
|
+
if a["id"] == action_id:
|
209
|
+
action = a
|
210
|
+
break
|
211
|
+
|
212
|
+
if not action:
|
213
|
+
raise ValueError(f"Action {action_id} not found")
|
214
|
+
|
215
|
+
if action["status"] != "approved":
|
216
|
+
raise ValueError(f"Action {action_id} is not approved")
|
217
|
+
|
218
|
+
# Import and execute the function
|
219
|
+
# Note: This is a simplified version - in practice, you'd need
|
220
|
+
# to properly reconstruct the function from module/name
|
221
|
+
function_name = action["function_name"]
|
222
|
+
args = tuple(action["args"])
|
223
|
+
kwargs = action["kwargs"]
|
224
|
+
|
225
|
+
# This would need to be implemented based on your specific requirements
|
226
|
+
# for function reconstruction and execution
|
227
|
+
raise NotImplementedError("Function execution from queue not implemented")
|
228
|
+
|
229
|
+
|
230
|
+
def list_pending_actions(cfg: Dict[str, Any]) -> List[Dict[str, Any]]:
|
231
|
+
"""
|
232
|
+
List all pending actions.
|
233
|
+
|
234
|
+
Args:
|
235
|
+
cfg: Configuration dictionary
|
236
|
+
|
237
|
+
Returns:
|
238
|
+
List of pending action dictionaries
|
239
|
+
"""
|
240
|
+
queue = get_action_queue(cfg)
|
241
|
+
return queue.get_pending_actions()
|
242
|
+
|
243
|
+
|
244
|
+
def format_action_summary(action: Dict[str, Any]) -> str:
|
245
|
+
"""
|
246
|
+
Format an action for display.
|
247
|
+
|
248
|
+
Args:
|
249
|
+
action: Action dictionary
|
250
|
+
|
251
|
+
Returns:
|
252
|
+
Human-readable action summary
|
253
|
+
"""
|
254
|
+
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(action["timestamp"]))
|
255
|
+
return (
|
256
|
+
f"ID: {action['id']}\n"
|
257
|
+
f"Function: {action['function_name']}\n"
|
258
|
+
f"Time: {timestamp}\n"
|
259
|
+
f"Args: {action['args']}\n"
|
260
|
+
f"Kwargs: {action['kwargs']}\n"
|
261
|
+
f"Status: {action['status']}"
|
262
|
+
)
|
smcp/cpu.py
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
"""
|
2
|
+
CPU core detection for licensing enforcement.
|
3
|
+
"""
|
4
|
+
import os
|
5
|
+
import logging
|
6
|
+
from typing import Optional
|
7
|
+
|
8
|
+
logger = logging.getLogger(__name__)
|
9
|
+
|
10
|
+
def get_physical_cores() -> int:
|
11
|
+
"""Get physical CPU core count."""
|
12
|
+
try:
|
13
|
+
import psutil
|
14
|
+
physical = psutil.cpu_count(logical=False)
|
15
|
+
if physical is None:
|
16
|
+
physical = psutil.cpu_count(logical=True)
|
17
|
+
return physical or 1
|
18
|
+
except ImportError:
|
19
|
+
# Fallback to os.cpu_count if psutil not available
|
20
|
+
return os.cpu_count() or 1
|
21
|
+
|
22
|
+
def get_cgroup_cores() -> Optional[int]:
|
23
|
+
"""Get CPU limit from cgroup v2."""
|
24
|
+
try:
|
25
|
+
with open("/sys/fs/cgroup/cpu.max", "r") as f:
|
26
|
+
content = f.read().strip()
|
27
|
+
if content == "max":
|
28
|
+
return None
|
29
|
+
|
30
|
+
parts = content.split()
|
31
|
+
if len(parts) >= 2:
|
32
|
+
quota = int(parts[0])
|
33
|
+
period = int(parts[1])
|
34
|
+
return quota // period
|
35
|
+
return None
|
36
|
+
except (FileNotFoundError, ValueError, PermissionError):
|
37
|
+
# Try cgroup v1
|
38
|
+
try:
|
39
|
+
with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us", "r") as f:
|
40
|
+
quota = int(f.read().strip())
|
41
|
+
with open("/sys/fs/cgroup/cpu/cpu.cfs_period_us", "r") as f:
|
42
|
+
period = int(f.read().strip())
|
43
|
+
|
44
|
+
if quota > 0:
|
45
|
+
return quota // period
|
46
|
+
return None
|
47
|
+
except (FileNotFoundError, ValueError, PermissionError):
|
48
|
+
return None
|
49
|
+
|
50
|
+
def detect_cores() -> int:
|
51
|
+
"""
|
52
|
+
Detect available CPU cores considering both physical and cgroup limits.
|
53
|
+
|
54
|
+
Returns:
|
55
|
+
Number of detected cores
|
56
|
+
"""
|
57
|
+
physical = get_physical_cores()
|
58
|
+
cgroup = get_cgroup_cores()
|
59
|
+
|
60
|
+
if cgroup is not None:
|
61
|
+
detected = min(physical, cgroup)
|
62
|
+
logger.debug(f"Core detection: physical={physical}, cgroup={cgroup}, detected={detected}")
|
63
|
+
else:
|
64
|
+
detected = physical
|
65
|
+
logger.debug(f"Core detection: physical={physical}, cgroup=None, detected={detected}")
|
66
|
+
|
67
|
+
return max(1, detected) # Ensure at least 1 core
|
smcp/decorators.py
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
"""
|
2
|
+
Decorator builders for securing MCP tools, prompts, and retrieval functions.
|
3
|
+
|
4
|
+
Provides a unified decorator factory that wraps the original MCP decorators
|
5
|
+
with conditional security guards.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from functools import wraps
|
9
|
+
from typing import Any, Callable, Dict, Optional
|
10
|
+
|
11
|
+
try:
|
12
|
+
from mcp import tool as base_tool, prompt as base_prompt, retrieval as base_retrieval
|
13
|
+
except ImportError:
|
14
|
+
# Fallback for testing or when mcp is not available
|
15
|
+
def base_tool(*args, **kwargs):
|
16
|
+
def decorator(fn):
|
17
|
+
return fn
|
18
|
+
return decorator
|
19
|
+
|
20
|
+
def base_prompt(*args, **kwargs):
|
21
|
+
def decorator(fn):
|
22
|
+
return fn
|
23
|
+
return decorator
|
24
|
+
|
25
|
+
def base_retrieval(*args, **kwargs):
|
26
|
+
def decorator(fn):
|
27
|
+
return fn
|
28
|
+
return decorator
|
29
|
+
|
30
|
+
from .filters import sanitize_prompt
|
31
|
+
from .allowlist import validate_host
|
32
|
+
from .confirm import maybe_queue, PendingApproval
|
33
|
+
from .logchain import log_event
|
34
|
+
|
35
|
+
|
36
|
+
def _secure(base_deco: Callable) -> Callable:
|
37
|
+
"""
|
38
|
+
Create a security-enhanced decorator factory from a base MCP decorator.
|
39
|
+
|
40
|
+
Args:
|
41
|
+
base_deco: The original MCP decorator (tool, prompt, or retrieval)
|
42
|
+
|
43
|
+
Returns:
|
44
|
+
A decorator factory that applies security guards conditionally
|
45
|
+
"""
|
46
|
+
def builder(*dargs, confirm: bool = False, **dkwargs) -> Callable:
|
47
|
+
"""
|
48
|
+
Build a secured decorator with optional confirmation requirement.
|
49
|
+
|
50
|
+
Args:
|
51
|
+
*dargs: Positional arguments passed to base decorator
|
52
|
+
confirm: Whether to require approval for destructive actions
|
53
|
+
**dkwargs: Keyword arguments passed to base decorator
|
54
|
+
|
55
|
+
Returns:
|
56
|
+
A decorator that applies security guards to the wrapped function
|
57
|
+
"""
|
58
|
+
def inner(fn: Callable) -> Callable:
|
59
|
+
@base_deco(*dargs, **dkwargs)
|
60
|
+
@wraps(fn)
|
61
|
+
def wrapper(*args, **kwargs) -> Any:
|
62
|
+
# Extract SMCP configuration injected by runtime adapter
|
63
|
+
cfg = kwargs.pop("_smcp_cfg", {})
|
64
|
+
|
65
|
+
# Input sanitization guard (activates if SAFE_RE configured)
|
66
|
+
if cfg.get("SAFE_RE"):
|
67
|
+
prompt_text = kwargs.get("prompt", "")
|
68
|
+
if prompt_text:
|
69
|
+
sanitize_prompt(prompt_text, cfg)
|
70
|
+
|
71
|
+
# Host allowlist guard (activates if ALLOWED_HOSTS configured)
|
72
|
+
if cfg.get("ALLOWED_HOSTS"):
|
73
|
+
target_host = kwargs.get("target", "")
|
74
|
+
if target_host:
|
75
|
+
validate_host(target_host, cfg)
|
76
|
+
|
77
|
+
# Destructive action confirmation guard
|
78
|
+
if confirm and maybe_queue(fn, args, kwargs, cfg):
|
79
|
+
raise PendingApproval(f"Action {fn.__name__} queued for approval")
|
80
|
+
|
81
|
+
# Execute the wrapped function
|
82
|
+
result = fn(*args, **kwargs)
|
83
|
+
|
84
|
+
# Audit logging guard (activates if LOG_PATH configured)
|
85
|
+
if cfg.get("LOG_PATH"):
|
86
|
+
log_event(fn.__name__, args, kwargs, result, cfg)
|
87
|
+
|
88
|
+
return result
|
89
|
+
return wrapper
|
90
|
+
return inner
|
91
|
+
return builder
|
92
|
+
|
93
|
+
|
94
|
+
# Create secured versions of MCP decorators
|
95
|
+
tool = _secure(base_tool)
|
96
|
+
prompt = _secure(base_prompt)
|
97
|
+
retrieval = _secure(base_retrieval)
|
smcp/enforce.py
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
"""
|
2
|
+
License enforcement with grace period and core monitoring.
|
3
|
+
"""
|
4
|
+
import os
|
5
|
+
import sys
|
6
|
+
import time
|
7
|
+
import threading
|
8
|
+
import logging
|
9
|
+
from typing import Optional
|
10
|
+
|
11
|
+
from .cpu import detect_cores
|
12
|
+
from .license import get_licensed_cores, get_customer_id
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
class EnforcementTimer:
|
17
|
+
"""Manages the 15-minute grace period timer."""
|
18
|
+
|
19
|
+
def __init__(self):
|
20
|
+
self._timer: Optional[threading.Timer] = None
|
21
|
+
self._lock = threading.Lock()
|
22
|
+
self._violations_logged = set()
|
23
|
+
|
24
|
+
def start_timer(self, used_cores: int, licensed_cores: int):
|
25
|
+
"""Start or restart the 15-minute grace period timer."""
|
26
|
+
with self._lock:
|
27
|
+
# Cancel existing timer
|
28
|
+
if self._timer:
|
29
|
+
self._timer.cancel()
|
30
|
+
|
31
|
+
# Log violation (once per cores combination)
|
32
|
+
violation_key = f"{used_cores}>{licensed_cores}"
|
33
|
+
if violation_key not in self._violations_logged:
|
34
|
+
logger.warning(
|
35
|
+
f"License violation: using {used_cores} cores, licensed for {licensed_cores}. "
|
36
|
+
f"Grace period: 15 minutes."
|
37
|
+
)
|
38
|
+
self._violations_logged.add(violation_key)
|
39
|
+
|
40
|
+
# Start new timer for 15 minutes (900 seconds)
|
41
|
+
self._timer = threading.Timer(900.0, self._enforce_shutdown)
|
42
|
+
self._timer.daemon = True
|
43
|
+
self._timer.start()
|
44
|
+
logger.info("Grace period timer started (15 minutes)")
|
45
|
+
|
46
|
+
def cancel_timer(self):
|
47
|
+
"""Cancel the grace period timer."""
|
48
|
+
with self._lock:
|
49
|
+
if self._timer:
|
50
|
+
self._timer.cancel()
|
51
|
+
self._timer = None
|
52
|
+
logger.info("Grace period timer cancelled - back within license limits")
|
53
|
+
|
54
|
+
def _enforce_shutdown(self):
|
55
|
+
"""Called when grace period expires."""
|
56
|
+
customer_id = get_customer_id() or "unknown"
|
57
|
+
used_cores = detect_cores()
|
58
|
+
licensed_cores = get_licensed_cores()
|
59
|
+
|
60
|
+
logger.error(
|
61
|
+
f"License enforcement: Grace period expired. "
|
62
|
+
f"Customer: {customer_id}, Used: {used_cores}, Licensed: {licensed_cores}"
|
63
|
+
)
|
64
|
+
|
65
|
+
# Check for emergency override
|
66
|
+
if os.getenv('BIZTEAM_EMERGENCY_OVERRIDE') == '1':
|
67
|
+
logger.critical("EMERGENCY OVERRIDE ACTIVE - Continuing with unlicensed usage")
|
68
|
+
# Exit after 30 seconds as specified
|
69
|
+
threading.Timer(30.0, lambda: os._exit(1)).start()
|
70
|
+
return
|
71
|
+
|
72
|
+
# Force shutdown
|
73
|
+
logger.critical("Shutting down due to license violation")
|
74
|
+
os._exit(1)
|
75
|
+
|
76
|
+
# Global enforcement timer instance
|
77
|
+
_enforcement_timer = EnforcementTimer()
|
78
|
+
|
79
|
+
def check_compliance() -> bool:
|
80
|
+
"""
|
81
|
+
Check if current core usage is within license limits.
|
82
|
+
|
83
|
+
Returns:
|
84
|
+
True if compliant, False if in violation
|
85
|
+
"""
|
86
|
+
used_cores = detect_cores()
|
87
|
+
licensed_cores = get_licensed_cores()
|
88
|
+
customer_id = get_customer_id() or "unknown"
|
89
|
+
|
90
|
+
# Log current status
|
91
|
+
logger.info(f"bizteam cores licensed={licensed_cores} used={used_cores}")
|
92
|
+
|
93
|
+
if used_cores <= licensed_cores:
|
94
|
+
# Within limits - cancel any active timer
|
95
|
+
_enforcement_timer.cancel_timer()
|
96
|
+
return True
|
97
|
+
else:
|
98
|
+
# Violation - start/restart timer
|
99
|
+
_enforcement_timer.start_timer(used_cores, licensed_cores)
|
100
|
+
return False
|
101
|
+
|
102
|
+
def start_enforcement():
|
103
|
+
"""Start the enforcement monitoring system."""
|
104
|
+
# Check for emergency flags
|
105
|
+
if os.getenv('BIZTEAM_IGNORE_CORES') == '1':
|
106
|
+
logger.warning("Core enforcement disabled via BIZTEAM_IGNORE_CORES")
|
107
|
+
# Still log usage but don't enforce
|
108
|
+
used_cores = detect_cores()
|
109
|
+
licensed_cores = get_licensed_cores()
|
110
|
+
logger.warning(f"UNLICENSED USAGE: cores used={used_cores}, licensed={licensed_cores}")
|
111
|
+
# Exit after 30 seconds as specified for emergency runs
|
112
|
+
threading.Timer(30.0, lambda: os._exit(0)).start()
|
113
|
+
return
|
114
|
+
|
115
|
+
# Initial compliance check
|
116
|
+
compliant = check_compliance()
|
117
|
+
|
118
|
+
if not compliant:
|
119
|
+
logger.warning("Starting in license violation state")
|
120
|
+
|
121
|
+
# Start periodic monitoring (every 30 seconds)
|
122
|
+
def monitor_loop():
|
123
|
+
while True:
|
124
|
+
time.sleep(30)
|
125
|
+
try:
|
126
|
+
check_compliance()
|
127
|
+
except Exception as e:
|
128
|
+
logger.error(f"Error during compliance check: {e}")
|
129
|
+
|
130
|
+
monitor_thread = threading.Thread(target=monitor_loop, daemon=True)
|
131
|
+
monitor_thread.start()
|
132
|
+
logger.info("License enforcement monitoring started")
|