first commit
This commit is contained in:
@@ -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 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
|
||||
/<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 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
|
||||
/<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
|
||||
|
||||
Flare’s 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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
Vendored
BIN
Binary file not shown.
@@ -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())
|
||||
Executable
+99
@@ -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 "$@"
|
||||
Reference in New Issue
Block a user