first commit
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user