feat: añadir agente de Windows y soporte para control remoto de ordenadores
This commit is contained in:
46
.github/workflows/windows-agent-release.yml
vendored
Normal file
46
.github/workflows/windows-agent-release.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Build Windows Agent (Release)
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-windows-agent:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pyinstaller
|
||||
|
||||
- name: Build hidden EXE with PyInstaller
|
||||
shell: bash
|
||||
run: |
|
||||
pyinstaller --noconfirm --clean --onefile --noconsole --name telesec-windows-agent python_sdk/windows_agent.py
|
||||
|
||||
- name: Upload workflow artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: telesec-windows-agent
|
||||
path: dist/telesec-windows-agent.exe
|
||||
|
||||
- name: Upload asset to GitHub Release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/telesec-windows-agent.exe
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -2,4 +2,13 @@ dist/*
|
||||
radata/*
|
||||
node_modules/*
|
||||
.DS_Store
|
||||
._*
|
||||
._*
|
||||
# Python
|
||||
__pycache__/*
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.egg-info/*
|
||||
*.egg
|
||||
.venv/*
|
||||
venv/*
|
||||
75
README.md
75
README.md
@@ -1,2 +1,77 @@
|
||||
# TeleSec
|
||||
Nuevo programa de datos
|
||||
|
||||
## Python SDK (CouchDB directo)
|
||||
|
||||
Se añadió un SDK Python en `python_sdk/` para acceder directamente a CouchDB (sin replicación local), compatible con el formato de cifrado de `TS_encrypt`:
|
||||
|
||||
- Formato: `RSA{...}`
|
||||
- Algoritmo: `CryptoJS.AES.encrypt(payload, secret)` (modo passphrase/OpenSSL)
|
||||
|
||||
### Instalación
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Uso rápido
|
||||
|
||||
```python
|
||||
from python_sdk import TeleSecCouchDB
|
||||
|
||||
db = TeleSecCouchDB(
|
||||
server_url="https://tu-couchdb",
|
||||
dbname="telesec",
|
||||
username="usuario",
|
||||
password="clave",
|
||||
secret="SECRET123",
|
||||
)
|
||||
|
||||
# Guardar cifrado (como TS_encrypt)
|
||||
db.put("personas", "abc123", {"nombre": "Ana"}, encrypt=True)
|
||||
|
||||
# Leer y descifrar
|
||||
obj = db.get("personas", "abc123", decrypt=True)
|
||||
|
||||
# Listar una tabla
|
||||
rows = db.list("personas", decrypt=True)
|
||||
for row in rows:
|
||||
print(row.id, row.data)
|
||||
```
|
||||
|
||||
API principal:
|
||||
|
||||
- `TeleSecCouchDB.put(table, item_id, data, encrypt=True)`
|
||||
- `TeleSecCouchDB.get(table, item_id, decrypt=True)`
|
||||
- `TeleSecCouchDB.list(table, decrypt=True)`
|
||||
- `TeleSecCouchDB.delete(table, item_id)`
|
||||
- `ts_encrypt(value, secret)` / `ts_decrypt(value, secret)`
|
||||
|
||||
## Agente Windows (Gest-Aula > Ordenadores)
|
||||
|
||||
Se añadió soporte para control de ordenadores del aula:
|
||||
|
||||
- Tabla: `aulas_ordenadores`
|
||||
- Campos reportados por agente: `Hostname`, `UsuarioActual`, `AppActualEjecutable`, `AppActualTitulo`, `LastSeenAt`
|
||||
- Control remoto: `ShutdownBeforeDate` (programado desde web a `hora_servidor + 2 minutos`)
|
||||
|
||||
### Ejecutar agente en Windows
|
||||
|
||||
```bash
|
||||
python -m python_sdk.windows_agent \
|
||||
--server "https://tu-couchdb" \
|
||||
--db "telesec" \
|
||||
--user "usuario" \
|
||||
--password "clave" \
|
||||
--secret "SECRET123"
|
||||
```
|
||||
|
||||
Opciones útiles:
|
||||
|
||||
- `--once`: una sola iteración
|
||||
- `--interval 15`: intervalo (segundos)
|
||||
- `--dry-run`: no apaga realmente, solo simula
|
||||
|
||||
### Hora de servidor (sin depender del reloj local)
|
||||
|
||||
El frontend y el agente usan la hora del servidor (cabecera HTTP `Date` de CouchDB) para comparar `ShutdownBeforeDate`.
|
||||
|
||||
3
python_sdk/__init__.py
Normal file
3
python_sdk/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .telesec_couchdb import TeleSecCouchDB, ts_encrypt, ts_decrypt
|
||||
|
||||
__all__ = ["TeleSecCouchDB", "ts_encrypt", "ts_decrypt"]
|
||||
293
python_sdk/telesec_couchdb.py
Normal file
293
python_sdk/telesec_couchdb.py
Normal file
@@ -0,0 +1,293 @@
|
||||
import base64
|
||||
import email.utils
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
|
||||
class TeleSecCryptoError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TeleSecCouchDBError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
|
||||
pad_len = block_size - (len(data) % block_size)
|
||||
return data + bytes([pad_len]) * pad_len
|
||||
|
||||
|
||||
def _pkcs7_unpad(data: bytes, block_size: int = 16) -> bytes:
|
||||
if not data or len(data) % block_size != 0:
|
||||
raise TeleSecCryptoError("Invalid padded data length")
|
||||
pad_len = data[-1]
|
||||
if pad_len < 1 or pad_len > block_size:
|
||||
raise TeleSecCryptoError("Invalid PKCS7 padding")
|
||||
if data[-pad_len:] != bytes([pad_len]) * pad_len:
|
||||
raise TeleSecCryptoError("Invalid PKCS7 padding bytes")
|
||||
return data[:-pad_len]
|
||||
|
||||
|
||||
def _evp_bytes_to_key(passphrase: bytes, salt: bytes, key_len: int, iv_len: int) -> tuple[bytes, bytes]:
|
||||
d = b""
|
||||
prev = b""
|
||||
while len(d) < key_len + iv_len:
|
||||
prev = hashlib.md5(prev + passphrase + salt).digest()
|
||||
d += prev
|
||||
return d[:key_len], d[key_len : key_len + iv_len]
|
||||
|
||||
|
||||
def _json_dumps_like_js(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def ts_encrypt(input_value: Any, secret: str) -> str:
|
||||
"""
|
||||
Compatible with JS: CryptoJS.AES.encrypt(payload, secret).toString()
|
||||
wrapped as RSA{<ciphertext>}.
|
||||
"""
|
||||
if secret is None or secret == "":
|
||||
if isinstance(input_value, str):
|
||||
return input_value
|
||||
return _json_dumps_like_js(input_value)
|
||||
|
||||
payload = input_value
|
||||
if not isinstance(input_value, str):
|
||||
try:
|
||||
payload = _json_dumps_like_js(input_value)
|
||||
except Exception:
|
||||
payload = str(input_value)
|
||||
|
||||
payload_bytes = payload.encode("utf-8")
|
||||
salt = os.urandom(8)
|
||||
key, iv = _evp_bytes_to_key(secret.encode("utf-8"), salt, 32, 16)
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||
encrypted = cipher.encrypt(_pkcs7_pad(payload_bytes, 16))
|
||||
openssl_blob = b"Salted__" + salt + encrypted
|
||||
b64 = base64.b64encode(openssl_blob).decode("utf-8")
|
||||
return f"RSA{{{b64}}}"
|
||||
|
||||
|
||||
def ts_decrypt(input_value: Any, secret: str) -> Any:
|
||||
"""
|
||||
Compatible with JS TS_decrypt behavior:
|
||||
- If not string: return as-is.
|
||||
- If RSA{...}: decrypt AES(CryptoJS passphrase mode), parse JSON when possible.
|
||||
- If plain string JSON: parse JSON.
|
||||
- Else: return raw string.
|
||||
"""
|
||||
if not isinstance(input_value, str):
|
||||
return input_value
|
||||
|
||||
is_wrapped = input_value.startswith("RSA{") and input_value.endswith("}")
|
||||
if is_wrapped:
|
||||
if not secret:
|
||||
raise TeleSecCryptoError("Secret is required to decrypt RSA payload")
|
||||
b64 = input_value[4:-1]
|
||||
try:
|
||||
raw = base64.b64decode(b64)
|
||||
except Exception as exc:
|
||||
raise TeleSecCryptoError("Invalid base64 payload") from exc
|
||||
|
||||
if len(raw) < 16 or not raw.startswith(b"Salted__"):
|
||||
raise TeleSecCryptoError("Unsupported encrypted payload format")
|
||||
|
||||
salt = raw[8:16]
|
||||
ciphertext = raw[16:]
|
||||
key, iv = _evp_bytes_to_key(secret.encode("utf-8"), salt, 32, 16)
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv=iv)
|
||||
decrypted = cipher.decrypt(ciphertext)
|
||||
decrypted = _pkcs7_unpad(decrypted, 16)
|
||||
|
||||
try:
|
||||
text = decrypted.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = decrypted.decode("latin-1")
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
try:
|
||||
return json.loads(input_value)
|
||||
except Exception:
|
||||
return input_value
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeleSecDoc:
|
||||
id: str
|
||||
data: Any
|
||||
raw: Dict[str, Any]
|
||||
|
||||
|
||||
class TeleSecCouchDB:
|
||||
"""
|
||||
Direct CouchDB client for TeleSec docs (_id = "<table>:<id>").
|
||||
No local replication layer.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str,
|
||||
dbname: str,
|
||||
secret: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
timeout: int = 30,
|
||||
session: Optional[requests.Session] = None,
|
||||
) -> None:
|
||||
self.server_url = server_url.rstrip("/")
|
||||
self.dbname = dbname
|
||||
self.secret = secret or ""
|
||||
self.timeout = timeout
|
||||
self.base_url = f"{self.server_url}/{quote(self.dbname, safe='')}"
|
||||
self.session = session or requests.Session()
|
||||
self.session.headers.update({"Accept": "application/json"})
|
||||
if username is not None:
|
||||
self.session.auth = (username, password or "")
|
||||
|
||||
def _iso_now(self) -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
def _doc_id(self, table: str, item_id: str) -> str:
|
||||
return f"{table}:{item_id}"
|
||||
|
||||
def _request(self, method: str, path: str = "", **kwargs) -> requests.Response:
|
||||
url = self.base_url if not path else f"{self.base_url}/{path.lstrip('/')}"
|
||||
kwargs.setdefault("timeout", self.timeout)
|
||||
res = self.session.request(method=method, url=url, **kwargs)
|
||||
return res
|
||||
|
||||
def get_server_datetime(self) -> datetime:
|
||||
"""
|
||||
Returns server datetime using HTTP Date header from CouchDB.
|
||||
Avoids reliance on local machine clock.
|
||||
"""
|
||||
candidates = [
|
||||
("HEAD", self.base_url),
|
||||
("GET", self.base_url),
|
||||
("HEAD", self.server_url),
|
||||
("GET", self.server_url),
|
||||
]
|
||||
for method, url in candidates:
|
||||
try:
|
||||
res = self.session.request(method=method, url=url, timeout=self.timeout)
|
||||
date_header = res.headers.get("Date")
|
||||
if not date_header:
|
||||
continue
|
||||
dt = email.utils.parsedate_to_datetime(date_header)
|
||||
if dt is None:
|
||||
continue
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
except Exception:
|
||||
continue
|
||||
raise TeleSecCouchDBError("Unable to retrieve server time from CouchDB Date header")
|
||||
|
||||
def iso_from_server_plus_minutes(self, minutes: int = 0) -> str:
|
||||
now = self.get_server_datetime()
|
||||
target = now.timestamp() + (minutes * 60)
|
||||
out = datetime.fromtimestamp(target, tz=timezone.utc)
|
||||
return out.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
def check_connection(self) -> Dict[str, Any]:
|
||||
res = self._request("GET")
|
||||
if res.status_code >= 400:
|
||||
raise TeleSecCouchDBError(f"CouchDB connection failed: {res.status_code} {res.text}")
|
||||
return res.json()
|
||||
|
||||
def get_raw(self, doc_id: str) -> Optional[Dict[str, Any]]:
|
||||
res = self._request("GET", quote(doc_id, safe=""))
|
||||
if res.status_code == 404:
|
||||
return None
|
||||
if res.status_code >= 400:
|
||||
raise TeleSecCouchDBError(f"GET doc failed: {res.status_code} {res.text}")
|
||||
return res.json()
|
||||
|
||||
def put_raw(self, doc: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if "_id" not in doc:
|
||||
raise ValueError("Document must include _id")
|
||||
res = self._request(
|
||||
"PUT",
|
||||
quote(doc["_id"], safe=""),
|
||||
headers={"Content-Type": "application/json"},
|
||||
data=_json_dumps_like_js(doc).encode("utf-8"),
|
||||
)
|
||||
if res.status_code >= 400:
|
||||
raise TeleSecCouchDBError(f"PUT doc failed: {res.status_code} {res.text}")
|
||||
return res.json()
|
||||
|
||||
def delete_raw(self, doc_id: str) -> bool:
|
||||
doc = self.get_raw(doc_id)
|
||||
if not doc:
|
||||
return False
|
||||
res = self._request("DELETE", f"{quote(doc_id, safe='')}?rev={quote(doc['_rev'], safe='')}")
|
||||
if res.status_code >= 400:
|
||||
raise TeleSecCouchDBError(f"DELETE doc failed: {res.status_code} {res.text}")
|
||||
return True
|
||||
|
||||
def put(self, table: str, item_id: str, data: Any, encrypt: bool = True) -> Dict[str, Any]:
|
||||
doc_id = self._doc_id(table, item_id)
|
||||
|
||||
if data is None:
|
||||
self.delete_raw(doc_id)
|
||||
return {"ok": True, "id": doc_id, "deleted": True}
|
||||
|
||||
existing = self.get_raw(doc_id)
|
||||
doc: Dict[str, Any] = existing if existing else {"_id": doc_id}
|
||||
|
||||
to_store = data
|
||||
is_encrypted_string = isinstance(data, str) and data.startswith("RSA{") and data.endswith("}")
|
||||
if encrypt and self.secret and not is_encrypted_string:
|
||||
to_store = ts_encrypt(data, self.secret)
|
||||
|
||||
doc["data"] = to_store
|
||||
doc["table"] = table
|
||||
doc["ts"] = self._iso_now()
|
||||
|
||||
return self.put_raw(doc)
|
||||
|
||||
def get(self, table: str, item_id: str, decrypt: bool = True) -> Optional[Any]:
|
||||
doc_id = self._doc_id(table, item_id)
|
||||
doc = self.get_raw(doc_id)
|
||||
if not doc:
|
||||
return None
|
||||
value = doc.get("data")
|
||||
if decrypt:
|
||||
return ts_decrypt(value, self.secret)
|
||||
return value
|
||||
|
||||
def delete(self, table: str, item_id: str) -> bool:
|
||||
return self.delete_raw(self._doc_id(table, item_id))
|
||||
|
||||
def list(self, table: str, decrypt: bool = True) -> List[TeleSecDoc]:
|
||||
params = {
|
||||
"include_docs": "true",
|
||||
"startkey": f'"{table}:"',
|
||||
"endkey": f'"{table}:\uffff"',
|
||||
}
|
||||
res = self._request("GET", "_all_docs", params=params)
|
||||
if res.status_code >= 400:
|
||||
raise TeleSecCouchDBError(f"LIST docs failed: {res.status_code} {res.text}")
|
||||
|
||||
rows = res.json().get("rows", [])
|
||||
out: List[TeleSecDoc] = []
|
||||
for row in rows:
|
||||
doc = row.get("doc") or {}
|
||||
item_id = row.get("id", "").split(":", 1)[1] if ":" in row.get("id", "") else row.get("id", "")
|
||||
value = doc.get("data")
|
||||
if decrypt:
|
||||
value = ts_decrypt(value, self.secret)
|
||||
out.append(TeleSecDoc(id=item_id, data=value, raw=doc))
|
||||
return out
|
||||
176
python_sdk/windows_agent.py
Normal file
176
python_sdk/windows_agent.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import argparse
|
||||
import ctypes
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import psutil
|
||||
|
||||
try:
|
||||
from .telesec_couchdb import TeleSecCouchDB, TeleSecCouchDBError, ts_decrypt
|
||||
except ImportError:
|
||||
from telesec_couchdb import TeleSecCouchDB, TeleSecCouchDBError, ts_decrypt
|
||||
|
||||
|
||||
def utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_iso(value: str) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
v = value.strip().replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(v)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_username() -> str:
|
||||
try:
|
||||
return psutil.Process().username() or os.getlogin()
|
||||
except Exception:
|
||||
try:
|
||||
return os.getlogin()
|
||||
except Exception:
|
||||
return os.environ.get("USERNAME", "")
|
||||
|
||||
|
||||
def _window_title(hwnd: int) -> str:
|
||||
buf_len = ctypes.windll.user32.GetWindowTextLengthW(hwnd)
|
||||
if buf_len <= 0:
|
||||
return ""
|
||||
buf = ctypes.create_unicode_buffer(buf_len + 1)
|
||||
ctypes.windll.user32.GetWindowTextW(hwnd, buf, buf_len + 1)
|
||||
return buf.value or ""
|
||||
|
||||
|
||||
def get_active_app() -> Dict[str, str]:
|
||||
exe = ""
|
||||
title = ""
|
||||
try:
|
||||
hwnd = ctypes.windll.user32.GetForegroundWindow()
|
||||
if hwnd:
|
||||
title = _window_title(hwnd)
|
||||
pid = ctypes.c_ulong()
|
||||
ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
|
||||
if pid.value:
|
||||
try:
|
||||
proc = psutil.Process(pid.value)
|
||||
exe = proc.name() or ""
|
||||
except Exception:
|
||||
exe = ""
|
||||
except Exception:
|
||||
pass
|
||||
return {"exe": exe, "title": title}
|
||||
|
||||
|
||||
def build_payload(machine_id: str) -> Dict[str, Any]:
|
||||
app = get_active_app()
|
||||
return {
|
||||
"Hostname": machine_id,
|
||||
"UsuarioActual": get_current_username(),
|
||||
"AppActualEjecutable": app.get("exe", ""),
|
||||
"AppActualTitulo": app.get("title", ""),
|
||||
# campo local diagnóstico (no se usa para decisión remota)
|
||||
"AgentLocalSeenAt": utcnow_iso(),
|
||||
}
|
||||
|
||||
|
||||
def should_shutdown(data: Dict[str, Any], server_now: datetime) -> bool:
|
||||
target = parse_iso(str(data.get("ShutdownBeforeDate", "") or ""))
|
||||
if not target:
|
||||
return False
|
||||
return server_now >= target
|
||||
|
||||
|
||||
def execute_shutdown(dry_run: bool = False) -> None:
|
||||
if dry_run:
|
||||
print("[DRY-RUN] Ejecutaría: shutdown /s /t 0 /f")
|
||||
return
|
||||
subprocess.run(["shutdown", "/s", "/t", "0", "/f"], check=False)
|
||||
|
||||
|
||||
def run_once(client: TeleSecCouchDB, machine_id: str, dry_run: bool = False) -> None:
|
||||
server_now = client.get_server_datetime()
|
||||
server_now_iso = server_now.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
raw = client.get(table="aulas_ordenadores", item_id=machine_id, decrypt=False)
|
||||
current: Dict[str, Any] = {}
|
||||
if raw is not None:
|
||||
current = ts_decrypt(raw, client.secret)
|
||||
if not isinstance(current, dict):
|
||||
current = {}
|
||||
|
||||
update = build_payload(machine_id)
|
||||
update["LastSeenAt"] = server_now_iso
|
||||
|
||||
for key in ["ShutdownBeforeDate", "ShutdownRequestedAt", "ShutdownRequestedBy"]:
|
||||
if key in current:
|
||||
update[key] = current.get(key)
|
||||
|
||||
client.put(table="aulas_ordenadores", item_id=machine_id, data=update, encrypt=True)
|
||||
|
||||
if should_shutdown(update, server_now):
|
||||
print(f"[{server_now_iso}] ShutdownBeforeDate alcanzado. Apagando {machine_id}...")
|
||||
execute_shutdown(dry_run=dry_run)
|
||||
else:
|
||||
print(
|
||||
f"[{server_now_iso}] Reportado {machine_id} user={update.get('UsuarioActual','')} "
|
||||
f"exe={update.get('AppActualEjecutable','')} title={update.get('AppActualTitulo','')}"
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="TeleSec Windows Agent")
|
||||
parser.add_argument("--server", required=True, help="CouchDB server URL, ej. https://couch.example")
|
||||
parser.add_argument("--db", default="telesec", help="Database name")
|
||||
parser.add_argument("--user", default="", help="CouchDB username")
|
||||
parser.add_argument("--password", default="", help="CouchDB password")
|
||||
parser.add_argument("--secret", required=True, help="TeleSec secret para cifrado")
|
||||
parser.add_argument("--machine-id", default="", help="ID de máquina (default: hostname)")
|
||||
parser.add_argument("--interval", type=int, default=15, help="Intervalo en segundos")
|
||||
parser.add_argument("--once", action="store_true", help="Ejecutar una sola iteración")
|
||||
parser.add_argument("--dry-run", action="store_true", help="No apagar realmente, solo log")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
machine_id = (args.machine_id or socket.gethostname() or "unknown-host").strip()
|
||||
|
||||
client = TeleSecCouchDB(
|
||||
server_url=args.server,
|
||||
dbname=args.db,
|
||||
secret=args.secret,
|
||||
username=args.user or None,
|
||||
password=args.password or None,
|
||||
)
|
||||
|
||||
try:
|
||||
client.check_connection()
|
||||
except TeleSecCouchDBError as exc:
|
||||
print(f"Error de conexión CouchDB: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.once:
|
||||
run_once(client=client, machine_id=machine_id, dry_run=args.dry_run)
|
||||
return 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
run_once(client=client, machine_id=machine_id, dry_run=args.dry_run)
|
||||
except Exception as exc:
|
||||
print(f"Error en iteración agente: {exc}", file=sys.stderr)
|
||||
time.sleep(max(5, args.interval))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1 +1,3 @@
|
||||
requests
|
||||
requests
|
||||
pycryptodome
|
||||
psutil
|
||||
@@ -48,6 +48,9 @@ PAGES.aulas = {
|
||||
<a class="button btn4" style="font-size: 25px;" href="#supercafe"
|
||||
><img src="${PAGES.supercafe.icon}" height="20" /> Ver comandas</a
|
||||
>
|
||||
<a class="button btn8" style="font-size: 25px;" href="#aulas,ordenadores"
|
||||
><img src="${PAGES.aulas.icon}" height="20" /> Control de ordenadores</a
|
||||
>
|
||||
</fieldset>
|
||||
<fieldset style="float: left;">
|
||||
<legend>Datos de hoy</legend>
|
||||
@@ -451,6 +454,211 @@ Cargando...</pre
|
||||
}
|
||||
};
|
||||
},
|
||||
__decryptIfNeeded: function (table, id, raw) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof raw !== 'string') {
|
||||
resolve(raw || {});
|
||||
return;
|
||||
}
|
||||
TS_decrypt(
|
||||
raw,
|
||||
SECRET,
|
||||
(data) => {
|
||||
resolve(data || {});
|
||||
},
|
||||
table,
|
||||
id
|
||||
);
|
||||
});
|
||||
},
|
||||
__getServerNow: async function () {
|
||||
try {
|
||||
var couchUrl = (localStorage.getItem('TELESEC_COUCH_URL') || '').replace(/\/$/, '');
|
||||
var couchUser = localStorage.getItem('TELESEC_COUCH_USER') || '';
|
||||
var couchPass = localStorage.getItem('TELESEC_COUCH_PASS') || '';
|
||||
var couchDb = localStorage.getItem('TELESEC_COUCH_DBNAME') || 'telesec';
|
||||
|
||||
if (couchUrl) {
|
||||
var target = couchUrl + '/' + encodeURIComponent(couchDb);
|
||||
var headers = {};
|
||||
if (couchUser) {
|
||||
headers['Authorization'] = 'Basic ' + btoa(couchUser + ':' + couchPass);
|
||||
}
|
||||
var res = await fetch(target, { method: 'HEAD', headers: headers });
|
||||
var dateHeader = res.headers.get('Date');
|
||||
if (dateHeader) {
|
||||
var dt = new Date(dateHeader);
|
||||
if (!isNaN(dt.getTime())) return dt;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('No se pudo obtener hora desde CouchDB', e);
|
||||
}
|
||||
|
||||
try {
|
||||
var wres = await fetch('https://worldtimeapi.org/api/timezone/Etc/UTC');
|
||||
if (wres.ok) {
|
||||
var wjson = await wres.json();
|
||||
if (wjson && wjson.utc_datetime) {
|
||||
var wdt = new Date(wjson.utc_datetime);
|
||||
if (!isNaN(wdt.getTime())) return wdt;
|
||||
}
|
||||
}
|
||||
} catch (e2) {
|
||||
console.warn('No se pudo obtener hora desde worldtimeapi', e2);
|
||||
}
|
||||
|
||||
return new Date();
|
||||
},
|
||||
__scheduleShutdown: async function (machineId) {
|
||||
try {
|
||||
document.getElementById('actionStatus').style.display = 'block';
|
||||
var serverNow = await PAGES.aulas.__getServerNow();
|
||||
var shutdownAt = new Date(serverNow.getTime() + 2 * 60 * 1000).toISOString();
|
||||
var raw = await DB.get('aulas_ordenadores', machineId);
|
||||
var data = await PAGES.aulas.__decryptIfNeeded('aulas_ordenadores', machineId, raw);
|
||||
data = data || {};
|
||||
data.Hostname = data.Hostname || machineId;
|
||||
data.ShutdownBeforeDate = shutdownAt;
|
||||
data.ShutdownRequestedAt = serverNow.toISOString();
|
||||
data.ShutdownRequestedBy = SUB_LOGGED_IN_ID || '';
|
||||
await DB.put('aulas_ordenadores', machineId, data);
|
||||
toastr.warning('Apagado programado antes de: ' + shutdownAt);
|
||||
} catch (e) {
|
||||
console.warn('Error programando apagado remoto', e);
|
||||
toastr.error('No se pudo programar el apagado remoto');
|
||||
} finally {
|
||||
document.getElementById('actionStatus').style.display = 'none';
|
||||
}
|
||||
},
|
||||
__cancelShutdown: async function (machineId) {
|
||||
try {
|
||||
document.getElementById('actionStatus').style.display = 'block';
|
||||
var raw = await DB.get('aulas_ordenadores', machineId);
|
||||
var data = await PAGES.aulas.__decryptIfNeeded('aulas_ordenadores', machineId, raw);
|
||||
data = data || {};
|
||||
data.Hostname = data.Hostname || machineId;
|
||||
data.ShutdownBeforeDate = '';
|
||||
data.ShutdownRequestedAt = '';
|
||||
data.ShutdownRequestedBy = '';
|
||||
await DB.put('aulas_ordenadores', machineId, data);
|
||||
toastr.success('Apagado remoto cancelado');
|
||||
} catch (e) {
|
||||
console.warn('Error cancelando apagado remoto', e);
|
||||
toastr.error('No se pudo cancelar el apagado remoto');
|
||||
} finally {
|
||||
document.getElementById('actionStatus').style.display = 'none';
|
||||
}
|
||||
},
|
||||
_ordenadores: function () {
|
||||
container.innerHTML = html`
|
||||
<a class="button" href="#aulas">← Volver a Gestión de Aulas</a>
|
||||
<h1>Control de ordenadores</h1>
|
||||
<p>
|
||||
Estado enviado por el agente Windows. El apagado remoto se programa con hora de servidor.
|
||||
</p>
|
||||
<div id="cont"></div>
|
||||
`;
|
||||
|
||||
TS_IndexElement(
|
||||
'aulas,ordenadores',
|
||||
[
|
||||
{ key: 'Hostname', type: 'raw', default: '', label: 'Hostname' },
|
||||
{ key: 'UsuarioActual', type: 'raw', default: '', label: 'Usuario actual' },
|
||||
{ key: 'AppActualEjecutable', type: 'raw', default: '', label: 'App actual (exe)' },
|
||||
{ key: 'AppActualTitulo', type: 'raw', default: '', label: 'App actual (título)' },
|
||||
{ key: 'LastSeenAt', type: 'raw', default: '', label: 'Último visto (server)' },
|
||||
{
|
||||
key: 'ShutdownBeforeDate',
|
||||
type: 'template',
|
||||
label: 'Apagado remoto',
|
||||
template: (data, td) => {
|
||||
var text = document.createElement('div');
|
||||
text.style.marginBottom = '6px';
|
||||
text.innerText = data.ShutdownBeforeDate
|
||||
? '⏻ Antes de: ' + data.ShutdownBeforeDate
|
||||
: 'Sin apagado programado';
|
||||
td.appendChild(text);
|
||||
|
||||
var btnOn = document.createElement('button');
|
||||
btnOn.className = 'rojo';
|
||||
btnOn.innerText = 'Programar +2m';
|
||||
btnOn.onclick = async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await PAGES.aulas.__scheduleShutdown(data._key);
|
||||
return false;
|
||||
};
|
||||
td.appendChild(btnOn);
|
||||
|
||||
if (data.ShutdownBeforeDate) {
|
||||
td.appendChild(document.createElement('br'));
|
||||
var btnCancel = document.createElement('button');
|
||||
btnCancel.className = 'btn5';
|
||||
btnCancel.innerText = 'Cancelar';
|
||||
btnCancel.onclick = async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await PAGES.aulas.__cancelShutdown(data._key);
|
||||
return false;
|
||||
};
|
||||
td.appendChild(btnCancel);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
'aulas_ordenadores',
|
||||
document.querySelector('#cont')
|
||||
);
|
||||
},
|
||||
_ordenadores__edit: function (mid) {
|
||||
var field_host = safeuuid();
|
||||
var field_user = safeuuid();
|
||||
var field_exe = safeuuid();
|
||||
var field_title = safeuuid();
|
||||
var field_seen = safeuuid();
|
||||
var field_shutdown = safeuuid();
|
||||
var btn_schedule = safeuuid();
|
||||
var btn_cancel = safeuuid();
|
||||
|
||||
container.innerHTML = html`
|
||||
<a class="button" href="#aulas,ordenadores">← Volver a ordenadores</a>
|
||||
<h1>Ordenador <code>${mid}</code></h1>
|
||||
<fieldset style="float: none; width: calc(100% - 40px);max-width: none;">
|
||||
<legend>Estado</legend>
|
||||
<label>Hostname<br /><input readonly id="${field_host}" /></label><br /><br />
|
||||
<label>Usuario actual<br /><input readonly id="${field_user}" /></label><br /><br />
|
||||
<label>App actual (exe)<br /><input readonly id="${field_exe}" /></label><br /><br />
|
||||
<label>App actual (título)<br /><input readonly id="${field_title}" /></label><br /><br />
|
||||
<label>Último visto (server)<br /><input readonly id="${field_seen}" /></label><br /><br />
|
||||
<label>ShutdownBeforeDate<br /><input readonly id="${field_shutdown}" /></label><br /><br />
|
||||
<button class="rojo" id="${btn_schedule}">Programar apagado +2m</button>
|
||||
<button class="btn5" id="${btn_cancel}">Cancelar apagado</button>
|
||||
</fieldset>
|
||||
`;
|
||||
|
||||
async function loadData() {
|
||||
var raw = await DB.get('aulas_ordenadores', mid);
|
||||
var data = await PAGES.aulas.__decryptIfNeeded('aulas_ordenadores', mid, raw);
|
||||
data = data || {};
|
||||
document.getElementById(field_host).value = data.Hostname || mid;
|
||||
document.getElementById(field_user).value = data.UsuarioActual || '';
|
||||
document.getElementById(field_exe).value = data.AppActualEjecutable || '';
|
||||
document.getElementById(field_title).value = data.AppActualTitulo || '';
|
||||
document.getElementById(field_seen).value = data.LastSeenAt || '';
|
||||
document.getElementById(field_shutdown).value = data.ShutdownBeforeDate || '';
|
||||
}
|
||||
|
||||
loadData();
|
||||
document.getElementById(btn_schedule).onclick = async () => {
|
||||
await PAGES.aulas.__scheduleShutdown(mid);
|
||||
await loadData();
|
||||
};
|
||||
document.getElementById(btn_cancel).onclick = async () => {
|
||||
await PAGES.aulas.__cancelShutdown(mid);
|
||||
await loadData();
|
||||
};
|
||||
},
|
||||
edit: function (fsection) {
|
||||
if (!checkRole('aulas')) {
|
||||
setUrlHash('index');
|
||||
@@ -467,6 +675,9 @@ Cargando...</pre
|
||||
case 'informes':
|
||||
this._informes();
|
||||
break;
|
||||
case 'ordenadores':
|
||||
this._ordenadores();
|
||||
break;
|
||||
default:
|
||||
this.index();
|
||||
break;
|
||||
@@ -480,6 +691,9 @@ Cargando...</pre
|
||||
case 'informes':
|
||||
this._informes__edit(item);
|
||||
break;
|
||||
case 'ordenadores':
|
||||
this._ordenadores__edit(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user