Files
zenai-kpc-python/status_webhook.py
T

294 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""
Standalone status webhook (stdlib only, HTTP + HTTPS).
Receives:
GET /status -> {"status": "IN"|"OUT"|"OFF"}
GET /?lokasi=IN|OUT&status=ON|OFF -> success | fail
State stored in JSON as IN | OUT | OFF.
Success (200) only when a valid transition applies; otherwise 400 and JSON unchanged:
- lokasi=IN, status=ON + current OFF -> IN
- lokasi=OUT, status=ON + current OFF -> OUT
- lokasi=IN, status=OFF + current IN -> OFF
- lokasi=OUT, status=OFF + current OUT -> OFF
Fixed ports: HTTP 8002, HTTPS 8443 (TLS cannot share a port with plain HTTP).
"""
from __future__ import annotations
import json
import os
import signal
import ssl
import subprocess
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
# Fixed ports — always the same (do not override via env).
HTTP_PORT = 8002
HTTPS_PORT = 8443
BASE_DIR = Path(os.path.dirname(os.path.abspath(__file__)))
STATE_FILE = Path(
os.getenv("STATUS_WEBHOOK_STATE", str(BASE_DIR / "status_webhook_state.json"))
)
CERT_FILE = Path(os.getenv("STATUS_WEBHOOK_CERT", str(BASE_DIR / "status_webhook_cert.pem")))
KEY_FILE = Path(os.getenv("STATUS_WEBHOOK_KEY", str(BASE_DIR / "status_webhook_key.pem")))
ALLOWED_LOKASI = frozenset({"IN", "OUT"})
ALLOWED_STATUS = frozenset({"ON", "OFF"})
ALLOWED_STORED = frozenset({"IN", "OUT", "OFF"})
_lock = threading.Lock()
def normalize_lokasi(value: str | None) -> str | None:
if value is None:
return None
value = value.strip().upper()
return value if value in ALLOWED_LOKASI else None
def normalize_request_status(value: str | None) -> str | None:
if value is None:
return None
value = value.strip().upper()
return value if value in ALLOWED_STATUS else None
def normalize_stored(value: str | None) -> str | None:
if value is None:
return None
value = str(value).strip().upper()
return value if value in ALLOWED_STORED else None
def load_status() -> str | None:
try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except (OSError, json.JSONDecodeError) as exc:
print(f"status_webhook: failed to read state from {STATE_FILE}: {exc}")
return None
return normalize_stored(data.get("status"))
def is_run_status(status: str | None = None) -> bool:
"""True when counter should run (IN or OUT)."""
if status is None:
status = load_status()
return normalize_stored(status) in ("IN", "OUT")
def save_status(status: str) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(".tmp")
payload = {"status": status}
tmp.write_text(json.dumps(payload) + "\n", encoding="utf-8")
tmp.replace(STATE_FILE)
def apply_transition(
lokasi: str, status: str, current: str | None
) -> tuple[str | None, bool]:
"""Return (new_status, ok). ok True only for a valid transition that stores."""
# Treat missing/empty state as OFF so first ON can start a session.
effective = current if current is not None else "OFF"
if status == "ON":
if effective == "OFF":
return lokasi, True
return None, False
# status == "OFF"
if effective == lokasi:
return "OFF", True
return None, False
def ensure_tls_certs() -> None:
if CERT_FILE.is_file() and KEY_FILE.is_file():
return
CERT_FILE.parent.mkdir(parents=True, exist_ok=True)
print(
f"status_webhook: generating self-signed TLS cert at {CERT_FILE} "
f"(set STATUS_WEBHOOK_CERT / STATUS_WEBHOOK_KEY to use your own)"
)
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-keyout",
str(KEY_FILE),
"-out",
str(CERT_FILE),
"-days",
"365",
"-nodes",
"-subj",
"/CN=status-webhook",
],
check=True,
capture_output=True,
text=True,
)
class StatusHandler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
print(f"status_webhook: {self.address_string()} - {fmt % args}")
def do_GET(self) -> None:
parsed = urlparse(self.path)
path = parsed.path.rstrip("/") or "/"
if path == "/status":
current = load_status() or "OFF"
self._send_json(200, {"status": current})
return
if path not in ("/", ""):
self._send_text(404, "Not Found")
return
params = parse_qs(parsed.query)
lokasi_values = params.get("lokasi", [])
status_values = params.get("status", [])
if (
not lokasi_values
or lokasi_values[0] == ""
or not status_values
or status_values[0] == ""
):
self._send_text(400, "fail")
return
lokasi = normalize_lokasi(lokasi_values[0])
status = normalize_request_status(status_values[0])
if lokasi is None or status is None:
self._send_text(400, "fail")
return
with _lock:
current = load_status()
new_status, ok = apply_transition(lokasi, status, current)
if not ok or new_status is None:
self._send_text(400, "fail")
return
save_status(new_status)
self._send_text(200, "success")
def _send_text(self, code: int, body: str) -> None:
raw = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def _send_json(self, code: int, payload: dict) -> None:
raw = json.dumps(payload).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def make_http_server(port: int = HTTP_PORT) -> ThreadingHTTPServer:
return ThreadingHTTPServer(("0.0.0.0", port), StatusHandler)
def make_https_server(port: int = HTTPS_PORT) -> ThreadingHTTPServer:
ensure_tls_certs()
server = ThreadingHTTPServer(("0.0.0.0", port), StatusHandler)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE))
server.socket = ctx.wrap_socket(server.socket, server_side=True)
return server
def _try_make_server(kind: str, factory) -> ThreadingHTTPServer | None:
try:
return factory()
except OSError as exc:
print(f"status_webhook: {kind} port already in use ({exc}); skipping bind")
return None
def start_background() -> tuple[ThreadingHTTPServer | None, ThreadingHTTPServer | None]:
"""Start HTTP + HTTPS servers on daemon threads. Safe if ports are taken."""
http_server = _try_make_server("HTTP", make_http_server)
https_server = _try_make_server("HTTPS", make_https_server)
if http_server is not None:
threading.Thread(
target=http_server.serve_forever,
name="status-webhook-http",
daemon=True,
).start()
print(
f"status_webhook HTTP http://0.0.0.0:{HTTP_PORT}/"
f"?lokasi=IN|OUT&status=ON|OFF"
)
if https_server is not None:
threading.Thread(
target=https_server.serve_forever,
name="status-webhook-https",
daemon=True,
).start()
print(
f"status_webhook HTTPS https://0.0.0.0:{HTTPS_PORT}/"
f"?lokasi=IN|OUT&status=ON|OFF"
)
print(f"status_webhook state file: {STATE_FILE}")
return http_server, https_server
def stop_servers(
http_server: ThreadingHTTPServer | None,
https_server: ThreadingHTTPServer | None,
) -> None:
for server in (http_server, https_server):
if server is None:
continue
try:
server.shutdown()
server.server_close()
except Exception as exc:
print(f"status_webhook: shutdown error: {exc}")
def main() -> None:
http_server, https_server = start_background()
if http_server is None and https_server is None:
raise SystemExit("status_webhook: neither HTTP nor HTTPS could bind")
stop = threading.Event()
def _handle_stop(_signum, _frame) -> None:
stop.set()
signal.signal(signal.SIGTERM, _handle_stop)
signal.signal(signal.SIGINT, _handle_stop)
try:
# serve_forever already running on threads; park main until stop.
while not stop.is_set():
stop.wait(3600)
finally:
print("status_webhook stopped")
stop_servers(http_server, https_server)
if __name__ == "__main__":
main()