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/*
|
radata/*
|
||||||
node_modules/*
|
node_modules/*
|
||||||
.DS_Store
|
.DS_Store
|
||||||
._*
|
._*
|
||||||
|
# Python
|
||||||
|
__pycache__/*
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.egg-info/*
|
||||||
|
*.egg
|
||||||
|
.venv/*
|
||||||
|
venv/*
|
||||||
75
README.md
75
README.md
@@ -1,2 +1,77 @@
|
|||||||
# TeleSec
|
# TeleSec
|
||||||
Nuevo programa de datos
|
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"
|
<a class="button btn4" style="font-size: 25px;" href="#supercafe"
|
||||||
><img src="${PAGES.supercafe.icon}" height="20" /> Ver comandas</a
|
><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>
|
||||||
<fieldset style="float: left;">
|
<fieldset style="float: left;">
|
||||||
<legend>Datos de hoy</legend>
|
<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) {
|
edit: function (fsection) {
|
||||||
if (!checkRole('aulas')) {
|
if (!checkRole('aulas')) {
|
||||||
setUrlHash('index');
|
setUrlHash('index');
|
||||||
@@ -467,6 +675,9 @@ Cargando...</pre
|
|||||||
case 'informes':
|
case 'informes':
|
||||||
this._informes();
|
this._informes();
|
||||||
break;
|
break;
|
||||||
|
case 'ordenadores':
|
||||||
|
this._ordenadores();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
this.index();
|
this.index();
|
||||||
break;
|
break;
|
||||||
@@ -480,6 +691,9 @@ Cargando...</pre
|
|||||||
case 'informes':
|
case 'informes':
|
||||||
this._informes__edit(item);
|
this._informes__edit(item);
|
||||||
break;
|
break;
|
||||||
|
case 'ordenadores':
|
||||||
|
this._ordenadores__edit(item);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user