first commit

This commit is contained in:
2026-06-09 17:31:10 -07:00
commit babfcb8666
7 changed files with 3298 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+421
View File
@@ -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 <printed-wrapper-url> )"` | bash, python3 |
| `wrapper.bash` | `eval "$( curl -s <printed-wrapper-url> )"` | bash 4+, curl, openssl |
| `wrapper.ps1` | `iex (irm <printed-wrapper-url>)` | PowerShell 5.1+ or pwsh 7+ |
All wrappers bake in the current runs:
- 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
/<GET_PATH>/
/<GET_PATH>/flare.html
/<GET_PATH>/wrapper.sh
/<GET_PATH>/wrapper.bash
/<GET_PATH>/wrapper.ps1
/<GET_PATH>/events?topic=...
```
That makes sessions harder to casually enumerate and keeps one runs browser-facing resources scoped to that run.
### 3. Randomized POST path
Event ingest lives at a separate per-run path, for example:
```text
/<POST_PATH>
```
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
Flares stable shape is:
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/<GET_PATH>/` | No | Dashboard |
| GET | `/<GET_PATH>/flare.html` | No | Dashboard file |
| GET | `/<GET_PATH>/wrapper.sh` | No | Bash + Python wrapper |
| GET | `/<GET_PATH>/wrapper.bash` | No | Pure Bash wrapper |
| GET | `/<GET_PATH>/wrapper.ps1` | No | PowerShell wrapper |
| GET | `/<GET_PATH>/events?topic=...&auth=...&since=all` | Token | SSE event stream |
| POST | `/<POST_PATH>` | 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.
+1586
View File
File diff suppressed because it is too large Load Diff
+969
View File
@@ -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:
/<GET_PATH>/ → flare.html dashboard (auto-configured via query params)
/<GET_PATH>/wrapper.* → curl-able wrappers with credentials pre-baked
/<GET_PATH>/events → SSE stream
/<POST_PATH> → 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 /<get_path>/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()
BIN
View File
Binary file not shown.
+223
View File
@@ -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 '<missing>'}")
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())
+99
View File
@@ -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 "$@"