2345627eb8
Mac mini is on macOS 26.5.2 Mac16,10 M4 - SpeechAnalyzer available
- Calendar/Contacts/Notes/Reminders use full /System/Applications/*.app path (more reliable)
- Reminders now returns assignment {assignee, source, available} with fallback to (Name) in title
- deco.js uses deco_ha_bridge.py for clients action (more reliable than old bridge)
- Added deco_ha_bridge.py (13KB RSA/AES Deco HA client)
- Added system_get_info + system_speech_api_status (verifies SpeechAnalyzer on macOS 26+)
- Preserved codex-image, gemini-image, gemini-chrome-prompt tools
- Updated README with all 23 tools + macOS 26 note + benchmark ref
384 lines
12 KiB
Python
384 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import secrets
|
|
import ssl
|
|
import subprocess
|
|
import sys
|
|
from urllib.parse import quote_plus
|
|
|
|
import requests
|
|
from Crypto.Cipher import AES, PKCS1_v1_5
|
|
from Crypto.PublicKey import RSA
|
|
from Crypto.Util.Padding import pad, unpad
|
|
from requests import RequestException
|
|
|
|
|
|
AES_KEY_BYTES = 16
|
|
MIN_AES_KEY = 10 ** (AES_KEY_BYTES - 1)
|
|
MAX_AES_KEY = (10**AES_KEY_BYTES) - 1
|
|
PKCS1_V1_5_HEADER_BYTES = 11
|
|
|
|
|
|
def load_env_file():
|
|
path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env")
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as env_file:
|
|
for line in env_file:
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
os.environ.setdefault(key, value.strip().strip("\"'"))
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def default_gateway():
|
|
try:
|
|
result = subprocess.run(
|
|
["/sbin/route", "-n", "get", "default"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
except Exception:
|
|
return None
|
|
|
|
for line in result.stdout.splitlines():
|
|
key, _, value = line.partition(":")
|
|
if key.strip() == "gateway":
|
|
return value.strip()
|
|
return None
|
|
|
|
|
|
def config():
|
|
load_env_file()
|
|
host = os.environ.get("DECO_HOST") or default_gateway()
|
|
password = os.environ.get("DECO_PASSWORD")
|
|
if not password:
|
|
raise RuntimeError("DECO_PASSWORD must be set in .env.")
|
|
if not host:
|
|
raise RuntimeError("DECO_HOST must be set in .env; default gateway detection failed.")
|
|
if not host.startswith(("http://", "https://")):
|
|
host = f"http://{host}"
|
|
verify_ssl = os.environ.get("DECO_VERIFY_SSL", "true").lower() not in {
|
|
"0",
|
|
"false",
|
|
"no",
|
|
}
|
|
return {
|
|
"host": host.rstrip("/"),
|
|
"username": os.environ.get("DECO_USERNAME", "admin"),
|
|
"password": password,
|
|
"verify_ssl": verify_ssl,
|
|
"timeout": int(os.environ.get("DECO_TIMEOUT", "10")),
|
|
}
|
|
|
|
|
|
def byte_len(n):
|
|
return (int(math.log2(n)) + 8) >> 3
|
|
|
|
|
|
def rsa_encrypt(n, e, plaintext):
|
|
public_key = RSA.construct((n, e)).publickey()
|
|
encryptor = PKCS1_v1_5.new(public_key)
|
|
block_size = byte_len(n)
|
|
bytes_per_block = block_size - PKCS1_V1_5_HEADER_BYTES
|
|
encrypted_text = ""
|
|
for index in range(0, len(plaintext), bytes_per_block):
|
|
encrypted_text += encryptor.encrypt(plaintext[index:index + bytes_per_block]).hex()
|
|
return encrypted_text
|
|
|
|
|
|
def decode_name(value):
|
|
if not value:
|
|
return value
|
|
try:
|
|
return base64.b64decode(value).decode()
|
|
except Exception:
|
|
return value
|
|
|
|
|
|
def title_from_snake(value):
|
|
if not value:
|
|
return value
|
|
return " ".join(part.title() for part in value.split("_"))
|
|
|
|
|
|
def deco_name(device):
|
|
return (
|
|
decode_name(device.get("custom_nickname"))
|
|
or title_from_snake(device.get("nickname"))
|
|
or device.get("device_model")
|
|
or device.get("mac")
|
|
)
|
|
|
|
|
|
class DecoApi:
|
|
def __init__(self, host, username, password, verify_ssl, timeout):
|
|
self.host = host
|
|
self.username = username
|
|
self.password = password
|
|
self.verify_ssl = verify_ssl
|
|
self.timeout = timeout
|
|
self.session = requests.Session()
|
|
self.aes_key = None
|
|
self.aes_iv = None
|
|
self.password_rsa_n = None
|
|
self.password_rsa_e = None
|
|
self.sign_rsa_n = None
|
|
self.sign_rsa_e = None
|
|
self.seq = None
|
|
self.stok = None
|
|
self.cookie = None
|
|
|
|
def generate_aes_key_and_iv(self):
|
|
self.aes_key = str(secrets.randbelow(MAX_AES_KEY - MIN_AES_KEY) + MIN_AES_KEY).encode()
|
|
self.aes_iv = str(secrets.randbelow(MAX_AES_KEY - MIN_AES_KEY) + MIN_AES_KEY).encode()
|
|
|
|
def post(self, context, path, params, data):
|
|
headers = {"Content-Type": "application/json"}
|
|
cookies = {}
|
|
if self.cookie:
|
|
name, _, value = self.cookie.partition("=")
|
|
if name and value:
|
|
cookies[name] = value
|
|
try:
|
|
response = self.session.post(
|
|
f"{self.host}{path}",
|
|
params=params,
|
|
data=data,
|
|
headers=headers,
|
|
cookies=cookies,
|
|
verify=self.verify_ssl,
|
|
timeout=self.timeout,
|
|
)
|
|
except RequestException:
|
|
response = self.curl_post(path, params, data, cookies)
|
|
if response.status_code == 403:
|
|
self.clear_auth()
|
|
raise RuntimeError(f"{context}: forbidden")
|
|
response.raise_for_status()
|
|
|
|
for cookie in response.headers.get("Set-Cookie", "").split(","):
|
|
match = re.search(r"(sysauth=[A-Za-z0-9]+)", cookie)
|
|
if match:
|
|
self.cookie = match.group(1)
|
|
break
|
|
|
|
result = response.json()
|
|
error_code = result.get("error_code")
|
|
if error_code not in (None, "", 0):
|
|
raise RuntimeError(f"{context}: response error_code={error_code}")
|
|
return result
|
|
|
|
def curl_post(self, path, params, data, cookies):
|
|
url = f"{self.host}{path}"
|
|
if params:
|
|
query = "&".join(f"{key}={quote_plus(str(value))}" for key, value in params.items())
|
|
url = f"{url}?{query}"
|
|
command = [
|
|
"curl",
|
|
"-s",
|
|
"-i",
|
|
"--connect-timeout",
|
|
str(self.timeout),
|
|
"-X",
|
|
"POST",
|
|
url,
|
|
"-H",
|
|
"Content-Type: application/json",
|
|
"--data-raw",
|
|
data,
|
|
]
|
|
if not self.verify_ssl:
|
|
command.insert(2, "-k")
|
|
if cookies:
|
|
command.extend(["-H", "Cookie: " + "; ".join(f"{k}={v}" for k, v in cookies.items())])
|
|
|
|
result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=self.timeout + 5)
|
|
head, separator, body = result.stdout.rpartition("\r\n\r\n")
|
|
if not separator:
|
|
head, _, body = result.stdout.rpartition("\n\n")
|
|
status_match = re.search(r"HTTP/\S+\s+(\d+)", head)
|
|
status = int(status_match.group(1)) if status_match else 200
|
|
response = requests.Response()
|
|
response.status_code = status
|
|
response._content = body.encode()
|
|
response.url = url
|
|
for line in head.splitlines():
|
|
if ":" not in line:
|
|
continue
|
|
key, value = line.split(":", 1)
|
|
response.headers[key.strip()] = value.strip()
|
|
return response
|
|
|
|
def fetch_keys(self):
|
|
response = self.post(
|
|
"Fetch keys",
|
|
"/cgi-bin/luci/;stok=/login",
|
|
{"form": "keys"},
|
|
json.dumps({"operation": "read"}),
|
|
)
|
|
keys = response["result"]["password"]
|
|
self.password_rsa_n = int(keys[0], 16)
|
|
self.password_rsa_e = int(keys[1], 16)
|
|
|
|
def fetch_auth(self):
|
|
response = self.post(
|
|
"Fetch auth",
|
|
"/cgi-bin/luci/;stok=/login",
|
|
{"form": "auth"},
|
|
json.dumps({"operation": "read"}),
|
|
)
|
|
auth = response["result"]
|
|
self.sign_rsa_n = int(auth["key"][0], 16)
|
|
self.sign_rsa_e = int(auth["key"][1], 16)
|
|
self.seq = auth["seq"]
|
|
|
|
def encode_payload(self, payload):
|
|
payload_json = json.dumps(payload, separators=(",", ":")).encode()
|
|
encrypted = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv).encrypt(
|
|
pad(payload_json, AES.block_size)
|
|
)
|
|
data = base64.b64encode(encrypted).decode()
|
|
sign = self.encode_sign(len(data))
|
|
return f"sign={sign}&data={quote_plus(data)}"
|
|
|
|
def encode_sign(self, data_len):
|
|
auth_hash = hashlib.md5(f"{self.username}{self.password}".encode()).hexdigest()
|
|
sign_text = (
|
|
f"k={self.aes_key.decode()}&i={self.aes_iv.decode()}&h={auth_hash}&s={self.seq + data_len}"
|
|
)
|
|
return rsa_encrypt(self.sign_rsa_n, self.sign_rsa_e, sign_text.encode())
|
|
|
|
def decrypt_data(self, context, data):
|
|
if not data:
|
|
self.clear_auth()
|
|
raise RuntimeError(f"{context}: empty data")
|
|
decrypted = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv).decrypt(
|
|
base64.b64decode(data)
|
|
)
|
|
return json.loads(unpad(decrypted, AES.block_size).decode())
|
|
|
|
def login(self):
|
|
if self.aes_key is None:
|
|
self.generate_aes_key_and_iv()
|
|
if self.password_rsa_n is None:
|
|
self.fetch_keys()
|
|
if self.seq is None:
|
|
self.fetch_auth()
|
|
encrypted_password = rsa_encrypt(
|
|
self.password_rsa_n,
|
|
self.password_rsa_e,
|
|
self.password.encode(),
|
|
)
|
|
response = self.post(
|
|
"Login",
|
|
"/cgi-bin/luci/;stok=/login",
|
|
{"form": "login"},
|
|
self.encode_payload({
|
|
"operation": "login",
|
|
"params": {"password": encrypted_password},
|
|
}),
|
|
)
|
|
data = self.decrypt_data("Login", response["data"])
|
|
if data.get("error_code") != 0:
|
|
result = data.get("result") or {}
|
|
attempts = result.get("attemptsAllowed", "unknown")
|
|
raise RuntimeError(f"Login failed: error_code={data.get('error_code')}; attempts={attempts}")
|
|
self.stok = data["result"]["stok"]
|
|
if not self.cookie:
|
|
raise RuntimeError("Login succeeded but no sysauth cookie was returned.")
|
|
|
|
def clear_auth(self):
|
|
self.seq = None
|
|
self.stok = None
|
|
self.cookie = None
|
|
|
|
def call(self, context, section, form, payload):
|
|
if not self.stok or not self.cookie:
|
|
self.login()
|
|
response = self.post(
|
|
context,
|
|
f"/cgi-bin/luci/;stok={self.stok}/admin/{section}",
|
|
{"form": form},
|
|
self.encode_payload(payload),
|
|
)
|
|
data = self.decrypt_data(context, response["data"])
|
|
error_code = data.get("error_code") or data.get("errorcode")
|
|
if error_code:
|
|
raise RuntimeError(f"{context}: decoded error_code={error_code}")
|
|
return data["result"]
|
|
|
|
def list_decos(self):
|
|
devices = self.call("List Devices", "device", "device_list", {"operation": "read"}).get("device_list", [])
|
|
return [
|
|
{
|
|
"name": deco_name(device),
|
|
"mac": device.get("mac"),
|
|
"ip": device.get("device_ip"),
|
|
"model": device.get("device_model"),
|
|
"hardwareVersion": device.get("hardware_ver"),
|
|
"firmwareVersion": device.get("software_ver"),
|
|
"role": device.get("role"),
|
|
"online": device.get("group_status") == "connected",
|
|
"connectionType": device.get("connection_type"),
|
|
}
|
|
for device in devices
|
|
]
|
|
|
|
def list_clients_for_deco(self, deco):
|
|
clients = self.call(
|
|
f"List Clients {deco['mac']}",
|
|
"client",
|
|
"client_list",
|
|
{"operation": "read", "params": {"device_mac": deco["mac"]}},
|
|
).get("client_list", [])
|
|
return [
|
|
{
|
|
"hostname": decode_name(client.get("name")),
|
|
"mac": client.get("mac"),
|
|
"ip": client.get("ip"),
|
|
"connection": client.get("connection_type"),
|
|
"interface": client.get("interface"),
|
|
"upSpeed": client.get("up_speed"),
|
|
"downSpeed": client.get("down_speed"),
|
|
"active": client.get("online"),
|
|
"linkedDecoMac": deco.get("mac"),
|
|
"linkedDecoName": deco.get("name"),
|
|
"linkedDecoRole": deco.get("role"),
|
|
}
|
|
for client in clients
|
|
if client.get("online")
|
|
]
|
|
|
|
|
|
def run():
|
|
if not config()["verify_ssl"]:
|
|
requests.packages.urllib3.disable_warnings()
|
|
ssl._create_default_https_context = ssl._create_unverified_context
|
|
deco = DecoApi(**config())
|
|
decos = deco.list_decos()
|
|
clients = {}
|
|
for node in decos:
|
|
if not node.get("mac"):
|
|
continue
|
|
for client in deco.list_clients_for_deco(node):
|
|
clients[client["mac"]] = client
|
|
return {"decos": decos, "clients": list(clients.values())}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
print(json.dumps(run(), indent=2))
|
|
except Exception as err:
|
|
print(json.dumps({"error": str(err)}), file=sys.stderr)
|
|
sys.exit(1)
|