diff --git a/script-api.py b/script-api.py index 31e980e..c8ab333 100755 --- a/script-api.py +++ b/script-api.py @@ -14,123 +14,237 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs -CONFIG_PATH = Path(sys.argv[1] if len(sys.argv) > 1 else "./commands.json") +# ============================================================ +# Configuration +# ============================================================ + +CONFIG_PATH = Path( + sys.argv[1] + if len(sys.argv) > 1 + else "./commands.json" +) + WEB_CSRF_TOKEN = secrets.token_urlsafe(32) +# ============================================================ +# Load configuration +# ============================================================ + def load_config(): - with CONFIG_PATH.open("r", encoding="utf-8") as f: + + with CONFIG_PATH.open( + "r", + encoding="utf-8" + ) as f: + cfg = json.load(f) - cfg.setdefault("host", "0.0.0.0") - cfg.setdefault("port", 8082) - cfg.setdefault("max_body_bytes", 65536) - cfg.setdefault("commands", {}) - if not cfg.get("api_key") or len(cfg["api_key"]) < 16: + cfg.setdefault( + "host", + "0.0.0.0" + ) + + cfg.setdefault( + "port", + 8082 + ) + + cfg.setdefault( + "max_body_bytes", + 65536 + ) + + cfg.setdefault( + "commands", + {} + ) + + + if not cfg.get("api_key"): + raise RuntimeError( - "Global api_key missing or shorter than 16 characters" + "Global api_key missing" + ) + + + if len(cfg["api_key"]) < 16: + + raise RuntimeError( + "api_key must contain at least 16 characters" + ) + + + if not isinstance( + cfg["commands"], + dict + ) or not cfg["commands"]: + + raise RuntimeError( + "No commands configured" ) - if not isinstance(cfg["commands"], dict) or not cfg["commands"]: - raise RuntimeError("No commands configured") validate_config(cfg) + return cfg + +# ============================================================ +# Validate configuration +# ============================================================ + def validate_config(cfg): for command_id, command in cfg["commands"].items(): + if not re.fullmatch( r"[a-zA-Z0-9_-]{1,64}", command_id ): + raise RuntimeError( f"Invalid command id: {command_id}" ) + if ( "command" not in command - or not isinstance(command["command"], list) + or not isinstance( + command["command"], + list + ) or not command["command"] ): + raise RuntimeError( f"Command '{command_id}' needs command list" ) - command.setdefault("args", {}) + + command.setdefault( + "args", + {} + ) + + command.setdefault( "timeout_seconds", 30 ) + if int(command["timeout_seconds"]) <= 0: + raise RuntimeError( f"Command '{command_id}' has invalid timeout" ) + + # + # Check placeholders + # + placeholders = set() + for part in command["command"]: + for match in re.findall( r"{([a-zA-Z0-9_]+)}", str(part) ): + placeholders.add(match) - missing = placeholders - set( - command["args"].keys() + + missing = ( + placeholders + - + set(command["args"].keys()) ) + if missing: + raise RuntimeError( f"Command '{command_id}' missing args: " - + ", ".join(sorted(missing)) + + + ", ".join(sorted(missing)) ) + + # + # Validate argument rules + # + for arg_name, rule in command["args"].items(): + if not re.fullmatch( r"[a-zA-Z0-9_]{1,64}", arg_name ): + raise RuntimeError( - f"Invalid arg name: {arg_name}" + f"Invalid argument name: {arg_name}" ) + + pattern = rule.get( "pattern", r"^[a-zA-Z0-9_.:/ -]{0,128}$" ) + try: + re.compile(pattern) + except re.error as exc: + raise RuntimeError( - f"Invalid regex {arg_name}: {exc}" + f"Invalid regex for {arg_name}: {exc}" ) + CONFIG = load_config() -class ApiHandler(BaseHTTPRequestHandler): +# ============================================================ +# HTTP Handler +# ============================================================ - server_version = "RpiScriptApi/2.0" +class ApiHandler( + BaseHTTPRequestHandler +): - def log_message(self, fmt, *args): + server_version = ( + "RpiScriptApi/3.0" + ) + + + + def log_message( + self, + fmt, + *args + ): print( "%s [%s] %s" @@ -143,33 +257,10 @@ class ApiHandler(BaseHTTPRequestHandler): ) - def send_json(self, status, payload): - - data = json.dumps( - payload, - indent=2 - ).encode("utf-8") - - - self.send_response(status) - - self.send_header( - "Content-Type", - "application/json; charset=utf-8" - ) - - self.send_header( - "Content-Length", - str(len(data)) - ) - - self.send_security_headers() - - self.end_headers() - - self.wfile.write(data) - + # -------------------------------------------------------- + # Responses + # -------------------------------------------------------- def send_security_headers(self): @@ -183,6 +274,11 @@ class ApiHandler(BaseHTTPRequestHandler): "DENY" ) + self.send_header( + "Referrer-Policy", + "no-referrer" + ) + self.send_header( "Cache-Control", "no-store" @@ -190,9 +286,97 @@ class ApiHandler(BaseHTTPRequestHandler): + def send_json( + self, + status, + payload + ): + + data = json.dumps( + payload, + indent=2 + ).encode( + "utf-8" + ) + + + self.send_response( + status + ) + + + self.send_header( + "Content-Type", + "application/json; charset=utf-8" + ) + + + self.send_header( + "Content-Length", + str(len(data)) + ) + + + self.send_security_headers() + + + self.end_headers() + + + self.wfile.write( + data + ) + + + + def send_html( + self, + status, + body + ): + + data = body.encode( + "utf-8" + ) + + + self.send_response( + status + ) + + + self.send_header( + "Content-Type", + "text/html; charset=utf-8" + ) + + + self.send_header( + "Content-Length", + str(len(data)) + ) + + + self.send_security_headers() + + + self.end_headers() + + + self.wfile.write( + data + ) + + + + # -------------------------------------------------------- + # Request body handling + # -------------------------------------------------------- + def read_body_raw(self): try: + length = int( self.headers.get( "Content-Length", @@ -200,19 +384,34 @@ class ApiHandler(BaseHTTPRequestHandler): ) ) + except ValueError: + raise ValueError( "invalid content-length" ) - if length > CONFIG["max_body_bytes"]: + + if length > int( + CONFIG["max_body_bytes"] + ): + raise ValueError( - "request too large" + "request body too large" ) - return self.rfile.read(length) + + if length == 0: + + return b"" + + + + return self.rfile.read( + length + ) @@ -220,16 +419,20 @@ class ApiHandler(BaseHTTPRequestHandler): raw = self.read_body_raw() + if not raw: + return {} + try: return json.loads( raw.decode("utf-8") ) + except json.JSONDecodeError as exc: raise ValueError( @@ -238,6 +441,107 @@ class ApiHandler(BaseHTTPRequestHandler): + def read_form_body(self): + + raw = self.read_body_raw() + + + if not raw: + + return {} + + + + content_type = self.headers.get( + "Content-Type", + "" + ) + + + if ( + "application/x-www-form-urlencoded" + not in + content_type + ): + + raise ValueError( + "invalid form content-type" + ) + + + + parsed = parse_qs( + raw.decode("utf-8"), + keep_blank_values=True + ) + + + return { + k: v[0] + if v + else "" + for k,v in parsed.items() + } + # -------------------------------------------------------- + # HTTP GET + # -------------------------------------------------------- + + def do_GET(self): + + path = urlparse( + self.path + ).path + + + if path == "/": + + self.send_html( + 200, + render_index() + ) + + return + + + + if path == "/health": + + self.send_json( + 200, + { + "ok": True + } + ) + + return + + + + if path == "/commands": + + self.send_json( + 200, + list_commands() + ) + + return + + + + self.send_json( + 404, + { + "ok": False, + "error": "not found" + } + ) + + + + # -------------------------------------------------------- + # HTTP POST + # -------------------------------------------------------- + def do_POST(self): path = urlparse( @@ -245,6 +549,10 @@ class ApiHandler(BaseHTTPRequestHandler): ).path + + # + # OpenHAB API + # if path == "/run": self.handle_run_api() @@ -252,6 +560,10 @@ class ApiHandler(BaseHTTPRequestHandler): return + + # + # Web Button UI + # web_match = re.fullmatch( r"/web/run/([a-zA-Z0-9_-]+)", path @@ -267,6 +579,7 @@ class ApiHandler(BaseHTTPRequestHandler): return + self.send_json( 404, { @@ -277,6 +590,10 @@ class ApiHandler(BaseHTTPRequestHandler): + # -------------------------------------------------------- + # API execution + # -------------------------------------------------------- + def handle_run_api(self): try: @@ -287,6 +604,7 @@ class ApiHandler(BaseHTTPRequestHandler): ) + if not secrets.compare_digest( api_key, CONFIG["api_key"] @@ -307,6 +625,7 @@ class ApiHandler(BaseHTTPRequestHandler): payload = self.read_json_body() + command_id = payload.get( "command" ) @@ -322,11 +641,13 @@ class ApiHandler(BaseHTTPRequestHandler): ) + command_cfg = CONFIG["commands"].get( command_id ) + if not command_cfg: self.send_json( @@ -347,6 +668,7 @@ class ApiHandler(BaseHTTPRequestHandler): ) + if not isinstance( args, dict @@ -357,18 +679,21 @@ class ApiHandler(BaseHTTPRequestHandler): ) + validated_args = validate_args( command_cfg, args ) + command = build_command( command_cfg, validated_args ) + result = run_command( command_id, command, @@ -376,12 +701,29 @@ class ApiHandler(BaseHTTPRequestHandler): ) + self.send_json( - 200 if result["ok"] else 500, + 200 + if result["ok"] + else + 500, result ) + + except subprocess.TimeoutExpired as exc: + + + self.send_json( + 504, + timeout_result( + "unknown", + exc + ) + ) + + except Exception as exc: @@ -392,9 +734,24 @@ class ApiHandler(BaseHTTPRequestHandler): "error": str(exc) } ) - def handle_run_web(self, command_id): - command_cfg = CONFIG["commands"].get(command_id) + + + # -------------------------------------------------------- + # Web UI execution + # -------------------------------------------------------- + + def handle_run_web( + self, + command_id + ): + + + command_cfg = CONFIG["commands"].get( + command_id + ) + + if not command_cfg: @@ -409,11 +766,13 @@ class ApiHandler(BaseHTTPRequestHandler): return + if not command_cfg.get( "web_enabled", False ): + self.send_html( 403, render_page( @@ -425,16 +784,21 @@ class ApiHandler(BaseHTTPRequestHandler): return + try: + form = self.read_form_body() + + csrf = form.get( "csrf", "" ) + if not secrets.compare_digest( csrf, WEB_CSRF_TOKEN @@ -445,34 +809,42 @@ class ApiHandler(BaseHTTPRequestHandler): ) + args = {} + + for name, rule in command_cfg.get( "args", {} ).items(): + if "web_default" in rule: args[name] = rule["web_default"] + elif "default" in rule: args[name] = rule["default"] + validated_args = validate_args( command_cfg, args ) + command = build_command( command_cfg, validated_args ) + result = run_command( command_id, command, @@ -480,6 +852,7 @@ class ApiHandler(BaseHTTPRequestHandler): ) + self.send_html( 200, render_result( @@ -489,8 +862,26 @@ class ApiHandler(BaseHTTPRequestHandler): ) + + except subprocess.TimeoutExpired as exc: + + + self.send_html( + 504, + render_result( + command_id, + timeout_result( + command_id, + exc + ) + ) + ) + + + except Exception as exc: + self.send_html( 400, render_page( @@ -501,6 +892,11 @@ class ApiHandler(BaseHTTPRequestHandler): +# ============================================================ +# Command handling functions +# ============================================================ + + def list_commands(): return { @@ -510,25 +906,34 @@ def list_commands(): "commands": [ { + "id": command_id, - "description": command.get( - "description", - "" - ), - "args": list( + + "description": command.get( - "args", - {} - ).keys() - ), - "web_enabled": bool( - command.get( - "web_enabled", - False + "description", + "" + ), + + "args": + list( + command.get( + "args", + {} + ).keys() + ), + + "web_enabled": + bool( + command.get( + "web_enabled", + False + ) ) - ) + } + for command_id, command in CONFIG["commands"].items() @@ -538,34 +943,41 @@ def list_commands(): +def validate_args( + command_cfg, + incoming_args +): -def validate_args(command_cfg, incoming_args): - - arg_rules = command_cfg.get( + rules = command_cfg.get( "args", {} ) + validated = {} - unknown = set( - incoming_args.keys() - ) - set( - arg_rules.keys() + + unknown = ( + set(incoming_args.keys()) + - + set(rules.keys()) ) + if unknown: raise ValueError( "unknown args: " - + ", ".join(sorted(unknown)) + + + ", ".join(sorted(unknown)) ) - for name, rule in arg_rules.items(): + for name, rule in rules.items(): + value = incoming_args.get( name, @@ -573,11 +985,13 @@ def validate_args(command_cfg, incoming_args): ) + if value in [ None, "" ]: + if rule.get( "required", False @@ -595,12 +1009,14 @@ def validate_args(command_cfg, incoming_args): value = str(value) + pattern = rule.get( "pattern", r"^[a-zA-Z0-9_.:/ -]{0,128}$" ) + if not re.fullmatch( pattern, value @@ -611,17 +1027,50 @@ def validate_args(command_cfg, incoming_args): ) + validated[name] = value + # + # Optional allowlist support + # + + allowlist = command_cfg.get( + "allowlist" + ) + + + if allowlist: + + + checked_values = [ + + validated.get(name) + + for name + + in command_cfg.get( + "allowlist_args", + [] + ) + + ] + + + if checked_values not in allowlist: + + raise ValueError( + "not allowed" + ) + + + return validated - - - - - -def build_command(command_cfg, args): +def build_command( + command_cfg, + args +): command = [] @@ -639,7 +1088,9 @@ def build_command(command_cfg, args): ) - command.append(value) + command.append( + value + ) return command @@ -648,7 +1099,15 @@ def build_command(command_cfg, args): -def run_command(command_id, command, command_cfg): +# ============================================================ +# Execute command +# ============================================================ + +def run_command( + command_id, + command, + command_cfg +): request_id = str( uuid.uuid4() @@ -684,11 +1143,12 @@ def run_command(command_id, command, command_cfg): } + env.update( { str(k): str(v) - for k,v + for k, v in command_cfg.get( "environment", @@ -728,13 +1188,16 @@ def run_command(command_id, command, command_cfg): duration_ms = int( - (time.time()-started)*1000 + (time.time() - started) + * + 1000 ) return { + "ok": completed.returncode == 0, @@ -755,6 +1218,10 @@ def run_command(command_id, command, command_cfg): completed.returncode, + "timed_out": + False, + + "duration_ms": duration_ms, @@ -772,11 +1239,72 @@ def run_command(command_id, command, command_cfg): +def timeout_result( + command_id, + exc +): + + return { + + "ok": + False, + + + "request_id": + str(uuid.uuid4()), + + + "command_id": + command_id, + + + "command": + getattr( + exc, + "cmd", + [] + ), + + + "exit_code": + None, + + + "timed_out": + True, + + + "duration_ms": + None, + + + "stdout": + exc.stdout + or + "", + + + "stderr": + exc.stderr + or + "command timed out" + + } + + + + + +# ============================================================ +# Web Interface +# ============================================================ + def render_index(): cards = [] + for command_id, command in CONFIG["commands"].items(): @@ -789,13 +1317,43 @@ def render_index(): + title = html.escape( + command.get( + "web_title", + command_id + ) + ) + + + + description = html.escape( + command.get( + "description", + "" + ) + ) + + + cards.append( f""" -
""" ) @@ -805,54 +1363,121 @@ def render_index(): if not cards: cards.append( - "No commands.
" + "No commands configured.
" ) + return render_page( + "Raspberry Pi Script API", + "\n".join(cards) + ) -def render_result(command_id, result): +def render_result( + command_id, + result +): - return render_page( - "Command Result", - - f""" --OK: + +Back + +
+ + +| OK | +{html.escape(str(result.get("ok")))} - + | +
|---|---|
| Exit Code | +{html.escape(str(result.get("exit_code")))} - + | +
| Duration | ++{html.escape(str(result.get("duration_ms")))} +ms + | +
| Request ID | ++{html.escape(str(result.get("request_id")))} + | +
{html.escape(result.get("stdout",""))}
+
+
+
{html.escape(result.get("stderr",""))}
+
"""
+
+ return render_page(
+ "Command Result",
+ body
)
-def render_page(title, body):
+def render_page(
+ title,
+ body
+):
return f"""
@@ -861,16 +1486,141 @@ def render_page(title, body):
-