commit babfcb86668bf51e037d4fa81957001368bd96f4 Author: lark Date: Tue Jun 9 17:31:10 2026 -0700 first commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..3dee883 Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7dcee1 --- /dev/null +++ b/README.md @@ -0,0 +1,421 @@ +# πŸ”₯ Flare + +Ephemeral, zero-dependency script telemetry for real-world automation. + +Run one Python file, open the dashboard URL it prints, source one wrapper line into a script, and watch that script report back in real time. + +Flare is built for short-lived operational work: + +- deployments +- provisioning +- migrations +- maintenance windows +- demos and labs +- one-off admin scripts + +It is intentionally **not** a long-term logging stack. There is no database, no accounts, no agents, and no setup beyond `python3 flare.py`. + +--- + +## What Flare Is + +Flare gives scripts and small automation jobs a disposable telemetry dashboard. + +Instead of hoping a job is still alive, your script can emit structured updates like: + +- starting +- installing packages +- waiting for service +- completed +- failed + +Those updates appear live in the browser, grouped by host. + +> **Flare is a disposable telemetry dashboard.** + +--- + +## Why It Exists + +Most observability tooling is too heavy for quick operational work. + +If all you want is a live view into a script, standing up a whole logging or monitoring platform is overkill. + +Flare id the opposite: + +- **fast to start** +- **easy to discard** + +--- + +## Quick Start + +```bash +python3 flare.py +``` + +That is all you need. + +On startup, Flare prints: + +- a ready-to-open dashboard URL +- wrapper import lines for bash / bash+python / PowerShell +- exportable environment variables if you want to post manually +- the exact per-run GET and POST capability URLs baked into this launch + +Open the dashboard URL in a browser, then paste one of the printed wrapper lines into a script. + +--- + +## Basic Workflow + +1. Start Flare +2. Open the dashboard URL from the startup banner +3. Source one wrapper into your script +4. Call helper functions like `mon`, `mon_detail`, `mon_complete`, or `mon_error` +5. Watch events arrive live in the browser +6. Export JSON or CSV if you want to keep the record + +All runtime data lives in memory only. + +--- + +## Project Layout + +```text +flare/ +β”œβ”€β”€ flare.py # server, wrappers, in-memory pub/sub +└── flare.html # browser dashboard +``` + +`flare.py` uses only the Python standard library. + +--- + +## Wrapper Options + +Pick whichever wrapper fits the target environment. + +| Wrapper | Command shape | Requirements | +|---|---|---| +| `wrapper.sh` | `eval "$( curl -s )"` | bash, python3 | +| `wrapper.bash` | `eval "$( curl -s )"` | bash 4+, curl, openssl | +| `wrapper.ps1` | `iex (irm )` | PowerShell 5.1+ or pwsh 7+ | + +All wrappers bake in the current run’s: + +- event destination URL +- session/topic identifier +- bearer token +- HMAC secret, unless disabled + +Your script only needs to call helper functions. + +```bash +mon "message" +mon_complete "message" +mon_error "message" +mon_detail "message" '{"key":"value"}' +``` + +- `mon` β€” normal running/progress update +- `mon_complete` β€” successful completion +- `mon_error` β€” failure / error state +- `mon_detail` β€” progress update with structured JSON detail + +Example: + +```bash +mon "starting backup" +mon_detail "installing packages" '{"packages":["nginx","fail2ban"]}' +mon_complete "backup finished" +``` + +--- + +## Summary Channel + +There is one reserved `mon_detail` title with special dashboard behavior: + +```bash +mon_detail "flare-summary" '{"site":"phx-1","phase":"patching","owner":"shane"}' +``` + +Events sent with title `flare-summary` are treated as summary metadata for that host. They populate the main detail area instead of appearing as a normal log entry. + +That is useful for: + +- current phase +- change ticket / runbook identifiers +- target version +- site / cluster / environment +- host-specific notes you want pinned in view + +--- + +## Example: Bash Script + +```bash +#!/usr/bin/env bash +set -euo pipefail + +# Use the exact printed wrapper URL from flare.py +eval "$( curl -sf http://SERVER:8080/REPLACE_WITH_PRINTED_GET_PATH/wrapper.bash )" || { + echo "flare unavailable, stubbing functions" >&2 + mon() { echo "[flare] $1" >&2; } + mon_complete() { mon "$1"; } + mon_error() { mon "$1"; } + mon_detail() { mon "$1"; } +} + +mon_detail "flare-summary" '{"role":"db","change":"CHG-1234"}' +mon "starting server hardening" +mon_detail "system update" '{"action":"apt-get upgrade"}' + +apt-get update -qq +apt-get upgrade -y -qq + +mon "configuring firewall" +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp +ufw --force enable + +mon_complete "hardening finished" +``` + +--- + +## Example: Cloud-Init / First Boot + +```yaml +#cloud-config +runcmd: + - | + eval "$( curl -sf http://SERVER:8080/REPLACE_WITH_PRINTED_GET_PATH/wrapper.bash )" + mon_detail "flare-summary" '{"phase":"first boot","source":"cloud-init"}' + mon "cloud-init starting on $(hostname)" + apt-get update -qq && apt-get upgrade -y -qq + mon "packages updated" + mon_complete "$(hostname) ready" +``` + +Use the exact printed wrapper URL in real usage. + +--- + +## Example: PowerShell + +```powershell +# Use the exact wrapper URL printed by flare.py +iex (irm http://SERVER:8080/REPLACE_WITH_PRINTED_GET_PATH/wrapper.ps1) + +mon_detail "flare-summary" '{"phase":"patching","window":"nightly"}' +mon "starting updates" +mon_detail "patching" '{"phase":"download"}' +mon_complete "updates complete" +``` + +--- + +## Security Model + +Flare is still lightweight, but it is more deliberate than a naive callback endpoint. + +### 1. Per-run session values + +Each launch generates fresh values unless you provide your own: + +- bearer token +- HMAC secret +- topic/session identifier +- randomized browser-facing GET base path +- randomized POST ingest path + +### 2. Randomized GET base path + +Browser-facing resources live under a per-run random path, for example: + +```text +// +//flare.html +//wrapper.sh +//wrapper.bash +//wrapper.ps1 +//events?topic=... +``` + +That makes sessions harder to casually enumerate and keeps one run’s browser-facing resources scoped to that run. + +### 3. Randomized POST path + +Event ingest lives at a separate per-run path, for example: + +```text +/ +``` + +That separates β€œcan view” from β€œcan submit.” Knowing the dashboard URL does not automatically reveal the POST target. + +### 4. Bearer auth + +Event streaming and publish operations are tied to a token. + +### 5. HMAC-signed event envelopes + +Wrappers sign their event content before sending it. The server validates those signatures before accepting events, and the dashboard can also surface verification status for received events. + +That means Flare is not just trusting any JSON that happens to hit the endpoint with the right shape. + +--- + +## Why the Paths Exist + +Flare uses capability-style per-run URLs. + +In plain English: + +- the dashboard should not live at a predictable public root +- wrappers should be scoped to the current run +- event streaming should belong to the same run-specific namespace +- message ingest should be isolated from browser-facing resources + +That is why the startup banner prints the **exact dashboard URL**, **exact wrapper URLs**, and **exact POST target** for that run. + +You are not meant to memorize or handcraft these paths. Just copy what Flare prints. + +--- + +## Dashboard Features + +The dashboard is intentionally lightweight and host-oriented. + +Current behavior includes: + +- real-time SSE event streaming +- host-tab grouping of activity +- running / complete / error visibility +- structured detail rendering +- reserved `flare-summary` metadata area +- JSON export +- CSV export +- direct open from the startup banner URL +- client-side session behavior with no database backend + +The dashboard URL is preconfigured with the session values it needs for that one run. + +--- + +## CLI Options + +```text +python3 flare.py [OPTIONS] + + --host HOST Bind address (default: 0.0.0.0) + --port PORT Port (default: 8080) + --token TOKEN Bearer token (generated if omitted) + --secret SECRET HMAC secret (generated if omitted) + --topic TOPIC Session/topic name (generated if omitted) + --html PATH Path to flare.html (auto-detected if omitted) + --get-path PATH Browser-facing GET base path (generated if omitted) + --post-path PATH POST ingest path (generated if omitted) + --no-secret Disable HMAC signing + --verbose Debug / validation logging +``` + +### Notes + +- `--topic` still exists internally, but the dashboard is host-oriented in practice. +- `--get-path` lets you pin the browser-facing path if you do not want a generated one. +- `--post-path` lets you pin the ingest path for controlled testing. +- `--verbose` is useful when testing signatures and request behavior. + +--- + +## Endpoints + +Flare’s stable shape is: + +| Method | Path | Auth | Purpose | +|---|---|---|---| +| GET | `//` | No | Dashboard | +| GET | `//flare.html` | No | Dashboard file | +| GET | `//wrapper.sh` | No | Bash + Python wrapper | +| GET | `//wrapper.bash` | No | Pure Bash wrapper | +| GET | `//wrapper.ps1` | No | PowerShell wrapper | +| GET | `//events?topic=...&auth=...&since=all` | Token | SSE event stream | +| POST | `/` | Token | Publish signed or unsigned event envelope | + +In normal usage, you should not need to hit these by hand. The startup banner and wrappers handle it for you. + +--- + +## Tests + +Two test helpers are included in this repo layout: + +- `http_test.py` β€” direct HTTP tests for weird paths, params, headers, methods, auth, SSE, and POST validation +- `src_test.py` β€” source the real wrappers, then exercise `mon`, `mon_detail`, `mon_complete`, and `mon_error` + +Both use one environment variable: + +```bash +export __FLARE_SRC='http://SERVER:8080/PRINTED_GET_PATH/wrapper.sh' +``` + +Then run: + +```bash +python3 http_test.py +bash src_test.py +``` + +`http_test.py` derives the dashboard, wrapper, SSE, and POST targets from the wrapper exports. `src_test.py` exercises both `wrapper.sh` and `wrapper.bash` from that same seed URL. + +--- + +## Architecture + +```text +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” source wrapper β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Remote Host β”‚ ◄──────────────────────── β”‚ flare.py β”‚ +β”‚ or Script β”‚ β”‚ server β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ mon() β”‚ ───── signed POST ──────► β”‚ in-memory β”‚ +β”‚ mon_detail() β”‚ β”‚ storage β”‚ +β”‚ mon_error() β”‚ β”‚ + SSE β”‚ +β”‚ mon_complete() β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ SSE + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ flare.html β”‚ + β”‚ dashboard β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Everything is intentionally ephemeral. If you need to keep the output, export JSON or CSV from the dashboard before shutting it down. + +--- + +## Tips + +### Graceful fallback + +If Flare is unavailable, stub the functions and let the script continue. + +### Use the printed URLs + +Because the GET and POST paths are generated per run, copying the banner output is the safest workflow. + + +### Export before exit + +Flare does not persist anything. If the session matters, export it before shutting the server down. + +--- + +## License + +Do whatever you want with it. diff --git a/flare.html b/flare.html new file mode 100644 index 0000000..bec9c3f --- /dev/null +++ b/flare.html @@ -0,0 +1,1586 @@ + + + + + + Flare // lark.cx + + + + + +
+
+
+
+
+
LARK.CX
+

// FLARE DASHBOARD

+
+
+
+
+
+
idle
+
+
β€”
+
+
+ +
+
+
+
waiting for hosts
+
+
+ +
+
+
+

+ NO HOST SELECTED +

+
+
+ +
+ +
+
+
+ + +
+
+
+
+
+
+
+ + + + diff --git a/flare.py b/flare.py new file mode 100644 index 0000000..31f0a79 --- /dev/null +++ b/flare.py @@ -0,0 +1,969 @@ +#!/usr/bin/env python3 +""" +flare.py β€” Ephemeral script monitoring server + dashboard +Pure stdlib, no dependencies. Drop alongside flare.html and run. + +Serves per-run resources under randomized paths: + // β†’ flare.html dashboard (auto-configured via query params) + //wrapper.* β†’ curl-able wrappers with credentials pre-baked + //events β†’ SSE stream + / β†’ signed event ingest + +Usage: + python3 flare.py [--port 8080] [--host 0.0.0.0] + [--token TOKEN] [--secret SECRET] + [--get-path PATH] [--post-path PATH] + [--html /path/to/flare.html] + +Generates random token, secret, topic, and paths if not provided. +Kill with Ctrl+C β€” all data lives in memory only. +""" + +import argparse +import hashlib +import hmac +import json +import logging +import os +import queue +import secrets +import socket +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from socketserver import ThreadingMixIn +from typing import Dict, List +from urllib.parse import urlparse, parse_qs + + +# Config (set from CLI args at startup) +CFG: Dict = {} + +# In-memory state +messages: Dict[str, List[str]] = {} +messages_lock = threading.Lock() + +subscribers: Dict[str, List[queue.Queue]] = {} +subscribers_lock = threading.Lock() + +def _topic_init(topic: str): + with messages_lock: + messages.setdefault(topic, []) + with subscribers_lock: + subscribers.setdefault(topic, []) + +def publish(topic: str, blob: dict): + """Store message and fan out to all SSE subscribers.""" + _topic_init(topic) + sse_event = { + "id": secrets.token_hex(6), + "time": int(time.time()), + "expires": int(time.time()) + 43200, + "event": "message", + "topic": topic, + "title": blob.get("title", ""), + "message": blob.get("message", ""), + "tags": blob.get("tags", []), + "priority": blob.get("priority", 3), + } + line = "data: " + json.dumps(sse_event) + "\n\n" + + with messages_lock: + messages[topic].append(line) + + with subscribers_lock: + for q in subscribers.get(topic, []): + try: + q.put_nowait(line) + except queue.Full: + pass + +def subscribe(topic: str) -> "queue.Queue[str]": + _topic_init(topic) + q: queue.Queue = queue.Queue(maxsize=256) + with subscribers_lock: + subscribers[topic].append(q) + return q + +def unsubscribe(topic: str, q: queue.Queue): + with subscribers_lock: + try: + subscribers[topic].remove(q) + except (ValueError, KeyError): + pass + +def history(topic: str) -> List[str]: + with messages_lock: + return list(messages.get(topic, [])) + + +def _skip_ws(text: str, idx: int) -> int: + while idx < len(text) and text[idx] in " \t\r\n": + idx += 1 + return idx + + + +def _extract_raw_content_json(envelope_text: str) -> str: + """Return the exact JSON substring used for envelope.content.""" + decoder = json.JSONDecoder() + idx = _skip_ws(envelope_text, 0) + + obj, _ = decoder.raw_decode(envelope_text, idx) + if not isinstance(obj, dict): + raise ValueError("Envelope must be a JSON object") + + idx = _skip_ws(envelope_text, idx) + if idx >= len(envelope_text) or envelope_text[idx] != "{": + raise ValueError("Envelope must begin with '{'") + + idx = _skip_ws(envelope_text, idx + 1) + key, idx = decoder.raw_decode(envelope_text, idx) + if key != "content": + raise ValueError("Envelope must begin with a content field") + + idx = _skip_ws(envelope_text, idx) + if idx >= len(envelope_text) or envelope_text[idx] != ":": + raise ValueError("Malformed content field") + + idx = _skip_ws(envelope_text, idx + 1) + _, end = decoder.raw_decode(envelope_text, idx) + return envelope_text[idx:end] + + +def _validate_event_blob(blob: dict) -> None: + """Raise ValueError when an incoming event fails validation.""" + secret = CFG.get("secret", "") + topic = blob.get("topic") + if not topic: + raise ValueError("Missing topic") + + if not secret: + return + + envelope_text = blob.get("message") + if not isinstance(envelope_text, str) or not envelope_text.strip(): + raise ValueError("Missing signed message envelope") + + try: + envelope = json.loads(envelope_text) + except json.JSONDecodeError as exc: + raise ValueError("Invalid signed message envelope") from exc + + if not isinstance(envelope, dict): + raise ValueError("Signed message envelope must be an object") + + content = envelope.get("content") + nonce = envelope.get("nonce") + supplied_hmac = envelope.get("hmac") + + if not isinstance(content, dict): + raise ValueError("Signed content must be an object") + if not isinstance(nonce, str) or not nonce: + raise ValueError("Missing nonce") + if not isinstance(supplied_hmac, str) or not supplied_hmac: + raise ValueError("Missing hmac") + + content_topic = content.get("topic") + if content_topic != topic: + raise ValueError("Topic mismatch between envelope and request body") + + raw_candidates = [] + try: + raw_candidates.append(_extract_raw_content_json(envelope_text)) + except ValueError: + pass + + canonical_content = json.dumps( + content, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ) + if canonical_content not in raw_candidates: + raw_candidates.append(canonical_content) + + secret_bytes = secret.encode("utf-8") + nonce_bytes = nonce.encode("utf-8") + for candidate in raw_candidates: + expected_hmac = hmac.new( + secret_bytes, + candidate.encode("utf-8") + nonce_bytes, + hashlib.sha256, + ).hexdigest() + if hmac.compare_digest(expected_hmac, supplied_hmac): + return + + raise ValueError("Invalid event HMAC") + +def _event_meta(blob: dict) -> dict: + """Best-effort metadata extraction for logging.""" + meta = { + "topic": blob.get("topic", ""), + "host": "", + "seq": "", + "title": blob.get("title", ""), + } + envelope_text = blob.get("message") + if not isinstance(envelope_text, str) or not envelope_text.strip(): + return meta + try: + envelope = json.loads(envelope_text) + except json.JSONDecodeError: + return meta + if not isinstance(envelope, dict): + return meta + content = envelope.get("content") + if not isinstance(content, dict): + return meta + meta["host"] = content.get("host", "") + meta["seq"] = content.get("seq", "") + meta["title"] = content.get("title", meta["title"]) + return meta + + +# Wrapper templates β€” credentials header is dynamic, body is static raw string +def _creds_header_bash(url, topic, token, secret): + """Bash-style credential exports.""" + insecure = "\nexport FLARE_INSECURE=1" if url.startswith("http://") else "" + return ( + f'export FLARE_URL="{url}"\n' + f'export FLARE_TOPIC="{topic}"\n' + f'export FLARE_TOKEN="{token}"\n' + f'export FLARE_SECRET="{secret}"' + f'{insecure}\n' + ) + +def _creds_header_ps1(url, topic, token, secret): + """PowerShell credential assignments.""" + return ( + f'$env:FLARE_URL = "{url}"\n' + f'$env:FLARE_TOPIC = "{topic}"\n' + f'$env:FLARE_TOKEN = "{token}"\n' + f'$env:FLARE_SECRET = "{secret}"\n' + ) + + +# wrapper.sh body (original bash + embedded python) +_WRAPPER_SH_BODY = r''' +_FLARE_SRC=$(cat << '__FLARE_EOF__' +#!/usr/bin/env python3 +import hashlib as h,hmac as _h,json as j,os,socket,ssl,sys,time,urllib.error as ue,urllib.request as ur +_E=os.environ.get +_cfg=None +def _d(m):print(f"flare: {m}",file=sys.stderr);sys.exit(1) +def _w(m):print(f"flare: {m}",file=sys.stderr) +def _ssl(): + if _E("FLARE_INSECURE","").strip()=="1": + c=ssl.create_default_context();c.check_hostname=False;c.verify_mode=ssl.CERT_NONE;return c +def _config(): + global _cfg + if _cfg:return _cfg + url=_E("FLARE_URL","").strip();topic=_E("FLARE_TOPIC","").strip();token=_E("FLARE_TOKEN","").strip() + if not url or not topic or not token:_d("FLARE_URL, FLARE_TOPIC, FLARE_TOKEN required") + raw_detail=_E("FLARE_DETAIL","") + try:detail=j.loads(raw_detail) if raw_detail else {} + except j.JSONDecodeError:detail={} + _cfg={"url":url,"topic":topic,"token":token,"secret":_E("FLARE_SECRET","").strip(), + "error":bool(_E("FLARE_ERROR")),"complete":bool(_E("FLARE_COMPLETE")), + "title":_E("FLARE_TITLE","(no title)"),"detail":detail} + return _cfg +def _sign(cnt,secret): + nonce=os.urandom(16).hex() + canon=j.dumps(cnt,sort_keys=True,separators=(",",":"),ensure_ascii=False) + mac=_h.new(secret.encode(),(canon+nonce).encode("utf-8"),h.sha256).hexdigest() + return nonce,mac +def _post(cfg,pay): + url=cfg["url"].rstrip("/")+"/";data=j.dumps(pay,separators=(",",":")).encode() + req=ur.Request(url,data=data,method="POST",headers={ + "Content-Type":"application/json","Authorization":f"Bearer {cfg['token']}"}) + ctx=_ssl() + for a in range(3): + try: + with ur.urlopen(req,timeout=10,context=ctx) as r: + if r.status==200:return True + _w(f"HTTP {r.status}") + except ue.HTTPError as e: + _w(f"HTTP {e.code}: {e.reason}") + if e.code<500:return False + except Exception as e:_w(f"Error: {e}") + if a<2:_w(f"Retrying ({a+2}/3)...");time.sleep(2) + return False +def send(): + cfg=_config() + cnt={"topic":cfg["topic"],"seq":int(time.time()*1000)&0xFFFFFFFF, + "timestamp":int(time.time()),"host":socket.getfqdn(), + "error":1 if cfg["error"] else 0,"complete":1 if cfg["complete"] else 0, + "title":cfg["title"],"detail":cfg["detail"]} + env={"content":cnt} + if cfg["secret"]:env["nonce"],env["hmac"]=_sign(cnt,cfg["secret"]) + tag="rotating_light" if cfg["error"] else "white_check_mark" if cfg["complete"] else "hourglass_flowing_sand" + ok=_post(cfg,{"topic":cfg["topic"],"title":cfg["title"], + "message":j.dumps(env,separators=(",",":")), + "tags":[tag],"priority":4 if cfg["error"] else 3}) + sgn=" [signed]" if cfg["secret"] else "" + lbl=" [ERROR]" if cfg["error"] else " [COMPLETE]" if cfg["complete"] else "" + print(f"{'OK' if ok else 'FAIL'} flare{lbl}{sgn}: {cfg['title']} -> {cfg['url']}/{cfg['topic']}",file=sys.stderr) + return ok +sys.exit(0 if send() else 1) +__FLARE_EOF__ +) + +_mon() { + local title="$1"; shift + local _c="" _e="" _d="" + while [[ $# -gt 0 ]]; do + case "$1" in + --complete) _c=1 ; shift ;; + --error) _e=1 ; shift ;; + --detail) _d="$2" ; shift 2 ;; + *) shift ;; + esac + done + export FLARE_TITLE="$title" + export FLARE_COMPLETE="$_c" + export FLARE_ERROR="$_e" + export FLARE_DETAIL="$_d" + echo "$_FLARE_SRC" | python3 + local rc=$? + unset FLARE_TITLE FLARE_COMPLETE FLARE_ERROR FLARE_DETAIL + return $rc +} + +mon() { _mon "$1"; } +mon_complete() { _mon "$1" --complete; } +mon_error() { _mon "$1" --error --complete; } +mon_detail() { _mon "$1" --detail "$2"; } + +export -f _mon mon mon_complete mon_error mon_detail +''' + +# wrapper.bash body (pure bash, no python) +_WRAPPER_BASH_BODY = r''' +_flare_post() { + local json="$1" + local url="${FLARE_URL%/}/" + local curl_opts=( -s -o /dev/null -w '%{http_code}' -X POST + -H "Content-Type: application/json" + -H "Authorization: Bearer ${FLARE_TOKEN}" + --max-time 10 ) + [[ "${FLARE_INSECURE:-}" == "1" ]] && curl_opts+=( -k ) + + local attempt code + for attempt in 1 2 3; do + code=$(curl "${curl_opts[@]}" -d "$json" "$url" 2>/dev/null) || code="000" + [[ "$code" == "200" ]] && return 0 + [[ "$code" =~ ^4 ]] && { echo "flare: HTTP $code (not retrying)" >&2; return 1; } + [[ $attempt -lt 3 ]] && { echo "flare: HTTP $code, retrying ($((attempt+1))/3)..." >&2; sleep 2; } + done + echo "flare: failed after 3 attempts (last HTTP $code)" >&2 + return 1 +} + +_flare_escape_json() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\n'/\\n}" + s="${s//$'\r'/\\r}" + s="${s//$'\t'/\\t}" + printf '%s' "$s" +} + +_flare_hmac() { + printf '%s' "$1" | openssl dgst -sha256 -hmac "$2" -hex 2>/dev/null | sed 's/^.* //' +} + +_mon() { + local title="$1"; shift + local _complete="" _error="" _detail_raw="" + while [[ $# -gt 0 ]]; do + case "$1" in + --complete) _complete=1 ; shift ;; + --error) _error=1 ; shift ;; + --detail) _detail_raw="$2"; shift 2 ;; + *) shift ;; + esac + done + + local host + host=$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo "unknown") + local ts + ts=$(date +%s) + local seq=$(( (ts * 1000 + RANDOM) & 0xFFFFFFFF )) + + local title_esc + title_esc=$(_flare_escape_json "$title") + + local detail="{}" + [[ -n "$_detail_raw" ]] && detail="$_detail_raw" + + local error_val=0 complete_val=0 + [[ -n "$_error" ]] && error_val=1 + [[ -n "$_complete" ]] && complete_val=1 + + local content + content=$(printf '{"complete":%d,"detail":%s,"error":%d,"host":"%s","seq":%d,"timestamp":%d,"title":"%s","topic":"%s"}' \ + "$complete_val" "$detail" "$error_val" "$(_flare_escape_json "$host")" "$seq" "$ts" "$title_esc" "$(_flare_escape_json "$FLARE_TOPIC")") + + local envelope + if [[ -n "${FLARE_SECRET:-}" ]]; then + local nonce + nonce=$(openssl rand -hex 16 2>/dev/null || head -c 16 /dev/urandom | od -A n -t x1 | tr -d ' \n') + local mac + mac=$(_flare_hmac "${content}${nonce}" "$FLARE_SECRET") + envelope=$(printf '{"content":%s,"hmac":"%s","nonce":"%s"}' "$content" "$mac" "$nonce") + else + envelope=$(printf '{"content":%s}' "$content") + fi + + local tag="hourglass_flowing_sand" priority=3 + if [[ $error_val -eq 1 ]]; then + tag="rotating_light"; priority=4 + elif [[ $complete_val -eq 1 ]]; then + tag="white_check_mark" + fi + + local envelope_esc + envelope_esc=$(_flare_escape_json "$envelope") + local payload + payload=$(printf '{"message":"%s","priority":%d,"tags":["%s"],"title":"%s","topic":"%s"}' \ + "$envelope_esc" "$priority" "$tag" "$title_esc" "$(_flare_escape_json "$FLARE_TOPIC")") + + _flare_post "$payload" + local rc=$? + + local sgn="" lbl="" + [[ -n "${FLARE_SECRET:-}" ]] && sgn=" [signed]" + [[ $error_val -eq 1 ]] && lbl=" [ERROR]" + [[ $complete_val -eq 1 && $error_val -eq 0 ]] && lbl=" [COMPLETE]" + echo "$( [[ $rc -eq 0 ]] && echo OK || echo FAIL ) flare${lbl}${sgn}: ${title} -> ${FLARE_URL}/${FLARE_TOPIC}" >&2 + return $rc +} + +mon() { _mon "$1"; } +mon_complete() { _mon "$1" --complete; } +mon_error() { _mon "$1" --error --complete; } +mon_detail() { _mon "$1" --detail "$2"; } + +export -f _flare_post _flare_escape_json _flare_hmac _mon mon mon_complete mon_error mon_detail +''' + +# wrapper.ps1 body (pure PowerShell) +_WRAPPER_PS1_BODY = r''' +if ($env:FLARE_URL -match '^http://') { + # Skip cert validation for plain HTTP / self-signed + if (-not ([System.Management.Automation.PSTypeName]'TrustAll').Type) { + Add-Type @" +using System.Net; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +public class TrustAll { + public static void Enable() { + ServicePointManager.ServerCertificateValidationCallback = + delegate { return true; }; + } +} +"@ + } + [TrustAll]::Enable() +} + +function _flare_post { + param([string]$Json) + $url = $env:FLARE_URL.TrimEnd('/') + '/' + $headers = @{ + 'Content-Type' = 'application/json' + 'Authorization' = "Bearer $env:FLARE_TOKEN" + } + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json) + + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + $r = Invoke-WebRequest -Uri $url -Method POST -Headers $headers ` + -Body $bytes -TimeoutSec 10 -UseBasicParsing -ErrorAction Stop + if ($r.StatusCode -eq 200) { return $true } + Write-Host "flare: HTTP $($r.StatusCode)" -ForegroundColor Yellow + } catch { + $code = 0 + if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode } + if ($code -ge 400 -and $code -lt 500) { + Write-Host "flare: HTTP $code (not retrying)" -ForegroundColor Red + return $false + } + Write-Host "flare: $($_.Exception.Message)" -ForegroundColor Yellow + } + if ($attempt -lt 3) { + Write-Host "flare: retrying ($($attempt+1)/3)..." -ForegroundColor Yellow + Start-Sleep -Seconds 2 + } + } + Write-Host "flare: failed after 3 attempts" -ForegroundColor Red + return $false +} + +function _flare_hmac { + param([string]$Message, [string]$Secret) + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret) + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Message)) + return ($hash | ForEach-Object { $_.ToString('x2') }) -join '' +} + +function _flare_escape_json { + param([string]$s) + $s = $s -replace '\\', '\\' -replace '"', '\"' + $s = $s -replace "`n", '\n' -replace "`r", '\r' -replace "`t", '\t' + return $s +} + +function _mon { + param( + [Parameter(Mandatory)][string]$Title, + [switch]$Complete, + [switch]$Error, + [string]$Detail = '' + ) + + $fqdn = try { [System.Net.Dns]::GetHostEntry('').HostName } catch { $env:COMPUTERNAME } + $epoch = [int][DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $seq = [long](([long][DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + (Get-Random -Max 1000)) % 4294967296) + $err = if ($Error) { 1 } else { 0 } + $comp = if ($Complete) { 1 } else { 0 } + + $detail_json = if ($Detail) { $Detail } else { '{}' } + + # Canonical key order (alphabetical) for HMAC + $host_esc = _flare_escape_json $fqdn + $title_esc = _flare_escape_json $Title + $topic_esc = _flare_escape_json $env:FLARE_TOPIC + + $content = "{`"complete`":$comp,`"detail`":$detail_json,`"error`":$err," + + "`"host`":`"$host_esc`",`"seq`":$seq,`"timestamp`":$epoch," + + "`"title`":`"$title_esc`",`"topic`":`"$topic_esc`"}" + + if ($env:FLARE_SECRET) { + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + $nb = New-Object byte[] 16; $rng.GetBytes($nb) + $nonce = ($nb | ForEach-Object { $_.ToString('x2') }) -join '' + $mac = _flare_hmac -Message ($content + $nonce) -Secret $env:FLARE_SECRET + $envelope = "{`"content`":$content,`"hmac`":`"$mac`",`"nonce`":`"$nonce`"}" + } else { + $envelope = "{`"content`":$content}" + } + + $tag = 'hourglass_flowing_sand'; $pri = 3 + if ($err -eq 1) { $tag = 'rotating_light'; $pri = 4 } + elseif ($comp -eq 1) { $tag = 'white_check_mark' } + + $env_esc = _flare_escape_json $envelope + $payload = "{`"message`":`"$env_esc`",`"priority`":$pri," + + "`"tags`":[`"$tag`"],`"title`":`"$title_esc`"," + + "`"topic`":`"$topic_esc`"}" + + $ok = _flare_post -Json $payload + + $sgn = if ($env:FLARE_SECRET) { ' [signed]' } else { '' } + $lbl = if ($err -eq 1) { ' [ERROR]' } elseif ($comp -eq 1) { ' [COMPLETE]' } else { '' } + $st = if ($ok) { 'OK' } else { 'FAIL' } + Write-Host "$st flare${lbl}${sgn}: $Title -> $env:FLARE_URL/$env:FLARE_TOPIC" +} + +function mon { param([string]$Title) _mon -Title $Title } +function mon_complete { param([string]$Title) _mon -Title $Title -Complete } +function mon_error { param([string]$Title) _mon -Title $Title -Error -Complete } +function mon_detail { param([string]$Title, [string]$Detail) _mon -Title $Title -Detail $Detail } +''' + +# Render functions +def render_wrapper_sh() -> str: + """wrapper.sh β€” bash + embedded Python (original, most compatible).""" + source_url, post_url, topic, token, secret = _cred_tuple() + header = ( + f'#!/usr/bin/env bash\n' + f'# ── flare wrapper (bash+python) ── sourced from {source_url}/wrapper.sh\n' + f'# Requires: bash, python3\n' + f'# Usage: eval "$( curl -s {source_url}/wrapper.sh )"\n\n' + ) + return header + _creds_header_bash(post_url, topic, token, secret) + _WRAPPER_SH_BODY + + +def render_wrapper_bash() -> str: + """wrapper.bash β€” pure bash, no Python needed.""" + source_url, post_url, topic, token, secret = _cred_tuple() + header = ( + f'#!/usr/bin/env bash\n' + f'# ── flare wrapper (pure bash) ── sourced from {source_url}/wrapper.bash\n' + f'# Requires: bash 4+, curl, openssl\n' + f'# Usage: eval "$( curl -s {source_url}/wrapper.bash )"\n\n' + ) + return header + _creds_header_bash(post_url, topic, token, secret) + _WRAPPER_BASH_BODY + + +def render_wrapper_ps1() -> str: + """wrapper.ps1 β€” pure PowerShell, no external dependencies.""" + source_url, post_url, topic, token, secret = _cred_tuple() + header = ( + f'# ── flare wrapper (PowerShell) ── sourced from {source_url}/wrapper.ps1\n' + f'# Requires: PowerShell 5.1+ (Windows) or pwsh 7+ (cross-platform)\n' + f'# Usage: iex (irm {source_url}/wrapper.ps1)\n\n' + ) + return header + _creds_header_ps1(post_url, topic, token, secret) + _WRAPPER_PS1_BODY + + +def _cred_tuple(): + return ( + CFG["get_base_url"], + CFG["post_url"], + CFG["default_topic"], + CFG["token"], + CFG.get("secret", ""), + ) + + +def _normalize_path(raw: str, label: str = "Path") -> str: + cleaned = (raw or "").strip().strip("/") + if not cleaned: + raise ValueError(f"{label} must not be empty") + return "/" + cleaned + + +def _normalize_get_path(raw: str) -> str: + return _normalize_path(raw, "GET path") + + +def _normalize_post_path(raw: str) -> str: + return _normalize_path(raw, "POST path") + + +def _strip_get_base(request_path: str) -> str | None: + base = CFG.get("get_base_path", "") + if not base: + return None + if request_path == base or request_path == base + "/": + return "/" + prefix = base + "/" + if request_path.startswith(prefix): + return "/" + request_path[len(prefix):].lstrip("/") + return None + +# HTTP handler +class Handler(BaseHTTPRequestHandler): + + def log_message(self, fmt, *args): + logging.debug(f"{self.address_string()} {fmt % args}") + + def _send(self, code: int, ctype: str, body: bytes): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + + def _auth(self, qs: dict = None) -> bool: + token = CFG.get("token", "") + if not token: + return True + if self.headers.get("Authorization", "") == f"Bearer {token}": + return True + if qs: + import base64 + raw = (qs.get("auth") or [None])[0] + if raw: + try: + decoded = base64.b64decode(raw + "==").decode("utf-8") + if decoded == f"Bearer {token}": + return True + except Exception: + pass + return False + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.end_headers() + + def do_GET(self): + parsed = urlparse(self.path) + raw_path = parsed.path or "/" + qs = parse_qs(parsed.query) + inner_path = _strip_get_base(raw_path) + if inner_path is None: + self._send(404, "text/plain", b"Not found") + return + path = inner_path.rstrip("/") or "/" + + # ── Serve wrappers (no auth β€” credentials are already scoped) ── + wrapper_map = { + "/wrapper.sh": ("text/plain; charset=utf-8", render_wrapper_sh), + "/wrapper.bash": ("text/plain; charset=utf-8", render_wrapper_bash), + "/wrapper.ps1": ("text/plain; charset=utf-8", render_wrapper_ps1), + } + if path in wrapper_map: + ctype, renderer = wrapper_map[path] + body = renderer().encode() + self._send(200, ctype, body) + return + + # ── Serve flare.html ── + if path in ("/", "/flare.html"): + html_path = CFG.get("html") + if html_path and Path(html_path).exists(): + body = Path(html_path).read_bytes() + self._send(200, "text/html; charset=utf-8", body) + else: + self._send(404, "text/plain", b"flare.html not found") + return + + # ── SSE stream: GET //events?topic=... ── + if path == "/events": + topic = (qs.get("topic") or [None])[0] + if topic: + if not self._auth(qs): + self._send(401, "text/plain", b"Unauthorized") + return + self._stream_sse(topic, qs) + return + + self._send(404, "text/plain", b"Not found") + + def _stream_sse(self, topic: str, qs: dict): + since_all = qs.get("since", [""])[0] == "all" + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("X-Accel-Buffering", "no") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + q = subscribe(topic) + try: + if since_all: + for line in history(topic): + self.wfile.write(line.encode()) + self.wfile.flush() + + while True: + try: + line = q.get(timeout=25) + self.wfile.write(line.encode()) + self.wfile.flush() + except queue.Empty: + self.wfile.write(b": ping\n\n") + self.wfile.flush() + + except (BrokenPipeError, ConnectionResetError): + pass + finally: + unsubscribe(topic, q) + + def do_POST(self): + parsed = urlparse(self.path) + req_path = (parsed.path or "/").rstrip("/") or "/" + post_path = (CFG.get("post_path") or "/").rstrip("/") or "/" + if req_path != post_path: + self._send(404, "text/plain", b"Not found") + return + + if not self._auth(): + self._send(401, "text/plain", b"Unauthorized") + return + + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + + try: + blob = json.loads(body) + except json.JSONDecodeError: + self._send(400, "text/plain", b"Invalid JSON") + return + + topic = blob.get("topic") + if not topic: + self._send(400, "text/plain", b"Missing topic") + return + + meta = _event_meta(blob) + try: + _validate_event_blob(blob) + if CFG.get("verbose"): + logging.debug( + "%s topic=%s host=%s seq=%s title=%r", + "HMAC validation OK" if CFG.get("secret") else "Unsigned mode accepted event", + meta.get("topic", ""), + meta.get("host", ""), + meta.get("seq", ""), + meta.get("title", ""), + ) + except ValueError as exc: + if CFG.get("verbose"): + logging.debug( + "HMAC validation FAILED topic=%s host=%s seq=%s title=%r reason=%s", + meta.get("topic", ""), + meta.get("host", ""), + meta.get("seq", ""), + meta.get("title", ""), + exc, + ) + self._send(400, "text/plain", str(exc).encode("utf-8")) + return + + publish(topic, blob) + resp = json.dumps({"id": secrets.token_hex(6), "time": int(time.time())}).encode() + self._send(200, "application/json", resp) + + +class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + + +# Startup +def _local_ip() -> str: + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "127.0.0.1" + + +def _generate_topic() -> str: + return secrets.token_urlsafe(18) + +def print_banner(host: str, port: int): + ip = _local_ip() if host in ("0.0.0.0", "") else host + root_url = f"http://{ip}:{port}" + get_url = CFG["get_base_url"] + post_url = CFG["post_url"] + token = CFG["token"] + secret = CFG.get("secret", "") + topic = CFG["default_topic"] + + dash_params = f"topic={topic}&token={token}" + if secret: + dash_params += f"&secret={secret}" + dash_url = f"{get_url}/?{dash_params}" + + div = "─" * 66 + + print() + print(" β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—") + print(" β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•") + print(" β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— ") + print(" β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β• ") + print(" β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—") + print(" β•šβ•β• β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β•β•šβ•β• β•šβ•β•β•šβ•β•β•β•β•β•β•") + print() + print(div) + print() + print(" Dashboard") + print(f" {dash_url}") + print() + print(div) + print() + print(" Source the wrapper in any script:") + print() + print(" bash+py") + print(f' eval "$( curl -s {get_url}/wrapper.sh )"') + print() + print(" bash") + print(f' eval "$( curl -s {get_url}/wrapper.bash )"') + print() + print(" pwsh") + print(f' iex (irm {get_url}/wrapper.ps1)') + print() + print(" Then call:") + print(' mon "starting backup"') + print(' mon_detail "installing" \'{"packages":["nginx","fail2ban"]}\'') + print(' mon_complete "all done"') + print(' mon_error "something broke"') + print() + print(div) + print() + print(" POST target") + print(f" {post_url}/") + print() + print(div) + print() + print(" ENV vars (if you prefer manual export):") + print() + print(f' export FLARE_URL="{post_url}"') + print(f' export FLARE_TOPIC="{topic}"') + print(f' export FLARE_TOKEN="{token}"') + if secret: + print(f' export FLARE_SECRET="{secret}"') + print() + print(div) + print() + print(" Listening on all interfaces β€” Ctrl+C to stop.") + print(" All data is in-memory, discarded on exit.") + print() + +def main(): + parser = argparse.ArgumentParser( + description="Flare β€” ephemeral script monitoring server + dashboard." + ) + parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)") + parser.add_argument("--port", default=8080, type=int, help="Port (default: 8080)") + parser.add_argument("--token", default="", help="Bearer token (generated if omitted)") + parser.add_argument("--secret", default="", help="HMAC secret (generated if omitted)") + parser.add_argument("--topic", default="", help="Default topic name (generated if omitted)") + parser.add_argument("--html", default="", help="Path to flare.html (auto-detected if omitted)") + parser.add_argument("--get-path", default="", help="Per-run GET base path (generated if omitted)") + parser.add_argument("--post-path", default="", help="Per-run POST path (generated if omitted)") + parser.add_argument("--no-secret", action="store_true", help="Disable HMAC secret generation") + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.WARNING, + format="%(asctime)s %(levelname)s %(message)s", + ) + + html_path = args.html + if not html_path: + candidate = Path(__file__).parent / "flare.html" + if candidate.exists(): + html_path = str(candidate) + + token = args.token or secrets.token_urlsafe(20) + secret = "" if args.no_secret else (args.secret or secrets.token_hex(16)) + topic = args.topic or _generate_topic() + get_path = _normalize_get_path(args.get_path or secrets.token_urlsafe(12)) + post_path = _normalize_post_path(args.post_path or secrets.token_urlsafe(18)) + + ip = _local_ip() if args.host in ("0.0.0.0", "") else args.host + root_url = f"http://{ip}:{args.port}" + + CFG.update({ + "token": token, + "secret": secret, + "default_topic": topic, + "html": html_path, + "root_url": root_url, + "get_base_path": get_path, + "get_base_url": f"{root_url}{get_path}", + "post_path": post_path, + "post_url": f"{root_url}{post_path}", + "verbose": args.verbose, + }) + + print_banner(args.host, args.port) + + server = ThreadedHTTPServer((args.host, args.port), Handler) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n\nServer stopped and data discarded.") + + +if __name__ == "__main__": + main() diff --git a/tests/.DS_Store b/tests/.DS_Store new file mode 100644 index 0000000..3e26736 Binary files /dev/null and b/tests/.DS_Store differ diff --git a/tests/http_test.py b/tests/http_test.py new file mode 100644 index 0000000..5aebbcf --- /dev/null +++ b/tests/http_test.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +import base64 +import hashlib +import hmac +import json +import os +import re +import secrets +import sys +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +import requests + + +@dataclass +class FlareConfig: + src_url: str + get_base_url: str + post_url: str + wrapper_sh_url: str + wrapper_bash_url: str + wrapper_ps1_url: str + dashboard_url: str + flare_html_url: str + events_url: str + topic: str + token: str + secret: str + + +def strip_query_fragment(url: str) -> str: + return url.split("#", 1)[0].split("?", 1)[0] + + +def derive_get_base(src_url: str) -> str: + clean = strip_query_fragment(src_url).rstrip("/") + for suffix in ("/wrapper.sh", "/wrapper.bash", "/wrapper.ps1"): + if clean.endswith(suffix): + return clean[: -len(suffix)] + return clean.rsplit("/", 1)[0] + + +def parse_wrapper_exports(wrapper_text: str) -> Dict[str, str]: + exports: Dict[str, str] = {} + patterns = [ + re.compile(r'^export\s+(FLARE_[A-Z_]+)="([^"]*)"$', re.MULTILINE), + re.compile(r'^\$env:(FLARE_[A-Z_]+)\s*=\s*"([^"]*)"$', re.MULTILINE), + ] + for pattern in patterns: + for key, value in pattern.findall(wrapper_text): + exports[key] = value + return exports + + +def load_config_from_src(src_url: str) -> FlareConfig: + get_base_url = derive_get_base(src_url) + wrapper = requests.get(src_url, timeout=10) + wrapper.raise_for_status() + exports = parse_wrapper_exports(wrapper.text) + + post_url = (exports.get("FLARE_URL") or "").rstrip("/") + topic = exports.get("FLARE_TOPIC", "") + token = exports.get("FLARE_TOKEN", "") + secret = exports.get("FLARE_SECRET", "") + + return FlareConfig( + src_url=src_url, + get_base_url=get_base_url, + post_url=post_url, + wrapper_sh_url=f"{get_base_url}/wrapper.sh", + wrapper_bash_url=f"{get_base_url}/wrapper.bash", + wrapper_ps1_url=f"{get_base_url}/wrapper.ps1", + dashboard_url=f"{get_base_url}/", + flare_html_url=f"{get_base_url}/flare.html", + events_url=f"{get_base_url}/events", + topic=topic, + token=token, + secret=secret, + ) + + +def create_signed_payload(topic: str, title: str, detail: Optional[Dict[str, Any]] = None, secret: str = "") -> Dict[str, Any]: + detail = detail or {} + content = { + "complete": 0, + "detail": detail, + "error": 0, + "host": "test-runner", + "seq": int(time.time() * 1000) & 0xFFFFFFFF, + "timestamp": int(time.time()), + "title": title, + "topic": topic, + } + envelope: Dict[str, Any] = {"content": content} + + if secret: + canon = json.dumps(content, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + nonce = secrets.token_hex(16) + mac = hmac.new(secret.encode("utf-8"), (canon + nonce).encode("utf-8"), hashlib.sha256).hexdigest() + envelope["nonce"] = nonce + envelope["hmac"] = mac + + return { + "topic": topic, + "title": title, + "message": json.dumps(envelope, separators=(",", ":"), ensure_ascii=False), + "tags": ["test"], + "priority": 3, + } + + +def auth_headers(token: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]: + headers = dict(extra or {}) + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def run_case( + description: str, + method: str, + url: str, + *, + expected_status: int, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, str]] = None, + json_body: Optional[Any] = None, + data: Optional[Any] = None, + stream: bool = False, +) -> bool: + try: + response = requests.request( + method=method, + url=url, + headers=headers, + params=params, + json=json_body, + data=data, + timeout=8, + stream=stream, + ) + ok = response.status_code == expected_status + status = "PASS" if ok else f"FAIL ({response.status_code})" + print(f"{status:<12} {method:<7} {response.request.path_url:<46} {description}") + if stream: + response.close() + return ok + except Exception as exc: + print(f"ERROR {method:<7} {url:<46} {description}: {exc}") + return False + + +def main() -> int: + src_url = os.environ.get("__FLARE_SRC", "").strip() + if not src_url: + print("ERROR: __FLARE_SRC is required", file=sys.stderr) + return 1 + + cfg = load_config_from_src(src_url) + root = f"{urlparse(cfg.get_base_url).scheme}://{urlparse(cfg.get_base_url).netloc}" + bad_post = root + "/definitely-not-real-post-path" + + print("Resolved Flare test target") + print(f" src : {cfg.src_url}") + print(f" get base : {cfg.get_base_url}") + print(f" post target : {cfg.post_url}") + print(f" dashboard : {cfg.dashboard_url}") + print(f" events : {cfg.events_url}") + print(f" topic : {cfg.topic or ''}") + print(f" token present : {'yes' if cfg.token else 'no'}") + print(f" secret present: {'yes' if cfg.secret else 'no'}") + print() + + print(f"{'RESULT':<12} {'METHOD':<7} {'PATH':<46} DESCRIPTION") + print("-" * 100) + + all_ok = True + + all_ok &= run_case("dashboard index", "GET", cfg.dashboard_url, expected_status=200) + all_ok &= run_case("dashboard HTML with extra query", "GET", cfg.flare_html_url, expected_status=200, params={"junk": "1"}) + all_ok &= run_case("wrapper.sh fetch", "GET", cfg.wrapper_sh_url, expected_status=200) + all_ok &= run_case("wrapper.bash fetch", "GET", cfg.wrapper_bash_url, expected_status=200) + all_ok &= run_case("wrapper.ps1 fetch", "GET", cfg.wrapper_ps1_url, expected_status=200) + all_ok &= run_case("root path stays dark", "GET", root + "/", expected_status=404) + all_ok &= run_case("weird GET path under root", "GET", root + "/definitely-not-real", expected_status=404) + all_ok &= run_case("unsupported HEAD currently returns 501", "HEAD", cfg.dashboard_url, expected_status=501) + all_ok &= run_case("CORS preflight on POST target", "OPTIONS", cfg.post_url, expected_status=204) + + all_ok &= run_case("events without auth rejected", "GET", cfg.events_url, expected_status=401, params={"topic": cfg.topic}, stream=True) + all_ok &= run_case("events with bearer auth", "GET", cfg.events_url, expected_status=200, headers=auth_headers(cfg.token), params={"topic": cfg.topic}, stream=True) + all_ok &= run_case("events history via since=all", "GET", cfg.events_url, expected_status=200, headers=auth_headers(cfg.token), params={"topic": cfg.topic, "since": "all"}, stream=True) + + if cfg.token: + auth_b64 = base64.b64encode(f"Bearer {cfg.token}".encode("utf-8")).decode("ascii") + all_ok &= run_case("events with auth query param", "GET", cfg.events_url, expected_status=200, params={"topic": cfg.topic, "auth": auth_b64}, stream=True) + all_ok &= run_case("POST missing auth rejected", "POST", cfg.post_url, expected_status=401, json_body={"topic": cfg.topic}) + all_ok &= run_case("POST bad bearer rejected", "POST", cfg.post_url, expected_status=401, headers=auth_headers("definitely-wrong-token"), json_body={"topic": cfg.topic}) + all_ok &= run_case("POST invalid JSON rejected", "POST", cfg.post_url, expected_status=400, headers=auth_headers(cfg.token, {"Content-Type": "application/json"}), data="{not-json") + all_ok &= run_case("POST missing topic rejected", "POST", cfg.post_url, expected_status=400, headers=auth_headers(cfg.token), json_body={"title": "no topic"}) + all_ok &= run_case("POST wrong path rejected", "POST", bad_post, expected_status=404, headers=auth_headers(cfg.token), json_body={"topic": cfg.topic}) + + if cfg.secret: + all_ok &= run_case("POST bad HMAC rejected", "POST", cfg.post_url, expected_status=400, headers=auth_headers(cfg.token), json_body=create_signed_payload(cfg.topic, "bad hmac", {"status": "bad"}, secret="wrong-secret")) + all_ok &= run_case("POST valid signed event", "POST", cfg.post_url, expected_status=200, headers=auth_headers(cfg.token), json_body=create_signed_payload(cfg.topic, "valid signed", {"status": "ok"}, secret=cfg.secret)) + all_ok &= run_case("POST signed flare-summary event", "POST", cfg.post_url, expected_status=200, headers=auth_headers(cfg.token), json_body=create_signed_payload(cfg.topic, "flare-summary", {"phase": "http_test", "status": "summary-ok"}, secret=cfg.secret)) + else: + all_ok &= run_case("POST unsigned event in no-secret mode", "POST", cfg.post_url, expected_status=200, headers=auth_headers(cfg.token), json_body=create_signed_payload(cfg.topic, "unsigned allowed", {"status": "ok"}, secret="")) + + all_ok &= run_case("PUT unsupported method returns 501", "PUT", cfg.post_url, expected_status=501, headers=auth_headers(cfg.token), json_body={"topic": cfg.topic}) + + print("-" * 100) + if all_ok: + print("All HTTP tests passed.") + return 0 + print("One or more HTTP tests failed.") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/src_test.sh b/tests/src_test.sh new file mode 100755 index 0000000..eef06c8 --- /dev/null +++ b/tests/src_test.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -uo pipefail + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "ERROR: $name is required" >&2 + exit 1 + fi +} + +strip_query_fragment() { + local s="$1" + s="${s%%\#*}" + s="${s%%\?*}" + printf '%s' "$s" +} + +derive_wrapper_urls() { + local src clean base + src="$1" + clean="$(strip_query_fragment "$src")" + + case "$clean" in + */wrapper.sh) base="${clean%/wrapper.sh}" ;; + */wrapper.bash) base="${clean%/wrapper.bash}" ;; + */wrapper.ps1) base="${clean%/wrapper.ps1}" ;; + */) base="${clean%/}" ;; + *) base="${clean%/*}" ;; + esac + + WRAPPER_SH_URL="$base/wrapper.sh" + WRAPPER_BASH_URL="$base/wrapper.bash" +} + +run_case() { + local desc="$1" + shift + printf 'Test: %-46s' "$desc" + if "$@"; then + echo "βœ… PASS" + else + local rc=$? + echo "❌ FAIL (rc=$rc)" + return "$rc" + fi +} + +exercise_wrapper() { + local label="$1" + local src_url="$2" + + ( + set +u + eval "$(curl -fsSL "$src_url")" || exit 90 + + command -v mon >/dev/null || exit 91 + command -v mon_detail >/dev/null || exit 92 + command -v mon_complete >/dev/null || exit 93 + command -v mon_error >/dev/null || exit 94 + + mon_detail "flare-summary" '{"source":"src_test","wrapper":"'"$label"'","phase":"bootstrap"}' || exit 100 + sleep 0.2 + mon "${label}: starting" || exit 101 + sleep 0.2 + mon_detail "${label}: detail blob" '{"nginx":"1.18.0","openssl":"3.0.2"}' || exit 102 + sleep 0.2 + mon_complete "${label}: completed task" || exit 103 + sleep 0.2 + mon_error "${label}: error case" || exit 104 + sleep 0.2 + mon "${label}: manual title" || exit 105 + ) +} + +main() { + require_env "__FLARE_SRC" + derive_wrapper_urls "$__FLARE_SRC" + + echo "Using source seed : $__FLARE_SRC" + echo "Derived wrapper.sh : $WRAPPER_SH_URL" + echo "Derived wrapper.bash: $WRAPPER_BASH_URL" + echo + + local failures=0 + + run_case "source wrapper.sh then use functions" exercise_wrapper "wrapper.sh" "$WRAPPER_SH_URL" || ((failures+=1)) + run_case "source wrapper.bash then use functions" exercise_wrapper "wrapper.bash" "$WRAPPER_BASH_URL" || ((failures+=1)) + + echo + if (( failures == 0 )); then + echo "🏁 All wrapper-source tests passed." + else + echo "🏁 Completed with $failures failing test group(s)." + exit 1 + fi +} + +main "$@"