released v1.3.2

This commit is contained in:
Pari Malam
2026-06-26 07:07:13 +08:00
parent 4ba5a6ce9b
commit 8bf575fdc9
30 changed files with 2469 additions and 1262 deletions
+368
View File
@@ -0,0 +1,368 @@
#!/bin/bash
#
# RunMe.sh Launcher for o11pro
#
# Incorporates all fixes from the analysis:
# - KID format patch (DRM key lookup)
# - Banner string patches (nulled!! -> Ap0dexMe0)
# - Provider config fixes (StreamsCount, PipeOutputCmdFormated)
# - Memory mitigation (-keep=false, GOMEMLIMIT)
# - Correct PATH for python3.13 dependencies
#
# Usage:
# ./RunMe.sh # Run with defaults (port 19999, verbose=2)
# ./RunMe.sh 8080 # Run on custom port
# ./RunMe.sh 8080 4 # Run on port 8080 with verbose=4 (trace)
# GOMEMLIMIT=4G ./RunMe.sh # Override memory limit
# MAX_STREAMS=8 ./RunMe.sh # Override max concurrent streams
# MONITOR=true ./RunMe.sh # Enable security monitoring proxy
# HLS_PROXY=false ./RunMe.sh # Disable HLS proxy
#
# Files required (in same directory as this script):
# o11pro Patched binary
# o11.cfg Global config
# providers/sample.cfg Provider config
# keys.txt DRM KID:key pairs (optional)
set -euo pipefail
# Configuration
# Security monitor: set to true to run through the HTTP proxy/monitor
# Architecture: Client -> :19998 (proxy/monitor) -> :19999 (real o11 API)
MONITOR="${MONITOR:-false}"
# Monitor proxy listen port (only used when MONITOR=true)
MONITOR_PORT="${MONITOR_PORT:-19998}"
# FIX: HLS Proxy config was missing entirely
# Architecture: Player -> :9999 (hls_proxy) -> upstream CDN
HLS_PROXY="${HLS_PROXY:-true}"
HLS_PROXY_PORT="${HLS_PROXY_PORT:-9999}"
HLS_PROXY_BIND="${HLS_PROXY_BIND:-0.0.0.0}"
HLS_PROXY_CONFIG="${HLS_PROXY_CONFIG:-/tmp/o11pro_orig_urls.json}"
# Path setup: need /usr/bin first so binary spawns python3.13 (has deps)
# instead of venv python3.12 (lacks deps like curl_cffi, cloudscraper)
# VENV_PATCHED
export PATH="/home/z/my-project/o11/venv/bin:/usr/bin:/bin:/usr/local/bin:${PATH:-}"
# Port for HTTP API and streaming (default 19999, override with $1)
PORT="${1:-19999}"
# FIX: was VERBOSE="${2:-2}" (always defaulted to 2 even when $2 was set)
VERBOSE="${2:-2}"
# Bind address: 0.0.0.0 = all interfaces (use 127.0.0.1 for localhost-only)
BIND="${BIND:-0.0.0.0}"
GOMEMLIMIT="${GOMEMLIMIT:-2GiB}"
KEEP_FALSE=true
MAX_STREAMS="${MAX_STREAMS:-0}"
HTTPS="${HTTPS:-false}"
ADMIN_USER="${ADMIN_USER:-}"
ADMIN_PASS="${ADMIN_PASS:-}"
# Working directory (where binary and configs live)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Pre-flight checks
echo "=========================================="
echo " o11pro launcher"
echo "=========================================="
echo ""
BINARY="o11pro"
if [ ! -f "$BINARY" ]; then
echo "ERROR: Binary '$BINARY' not found in $SCRIPT_DIR"
exit 1
fi
# Verify the binary has the KID patch (not the original REDA bug)
KID_PATCH=$(python3 -c "
with open('$BINARY','rb') as f:
f.seek(0x15625cd)
b = f.read(4)
print('OK' if b == b'%02x' else 'MISSING')
" 2>/dev/null || echo "CHECK_FAILED")
if [ "$KID_PATCH" != "OK" ]; then
echo "WARNING: KID format patch not detected in binary!"
echo ""
fi
# Verify the string patch (nulled!! -> Ap0dexMe0)
STRING_PATCH=$(python3 -c "
with open('$BINARY','rb') as f:
f.seek(0x1a6c350)
b = f.read(9)
print('OK' if b == b'Ap0dexMe0' else 'MISSING')
" 2>/dev/null || echo "CHECK_FAILED")
if [ "$STRING_PATCH" != "OK" ]; then
echo "NOTE: String patch (nulled!! -> Ap0dexMe0) not detected."
echo ""
fi
chmod +x "$BINARY"
if [ ! -f "o11.cfg" ]; then
echo "ERROR: Global config 'o11.cfg' not found"
exit 1
fi
if [ ! -f "providers/sample.cfg" ]; then
echo "ERROR: Provider config 'providers/sample.cfg' not found"
exit 1
fi
if [ ! -f "keys.txt" ]; then
echo "WARNING: keys.txt not found encrypted streams will fail"
echo ""
else
KEY_COUNT=$(grep -c ':' keys.txt 2>/dev/null || echo 0)
echo "Found keys.txt with $KEY_COUNT KID:key pairs"
fi
PYTHON_DEPS_OK=$(python3 -c "
try:
import curl_cffi, cloudscraper, dns, requests, requests_toolbelt, socks
print('OK')
except ImportError as e:
print('MISSING: ' + str(e))
" 2>/dev/null || echo "CHECK_FAILED")
if [ "$PYTHON_DEPS_OK" != "OK" ]; then
echo "WARNING: Python dependencies missing: $PYTHON_DEPS_OK"
echo ""
fi
# Prepare directories
echo "Preparing directories..."
mkdir -p hls/live keys epg dl manifests offair overlay logos fonts rec scripts logs providers
if [ -f "keys.txt" ] && [ ! -f "keys/keys.txt" ]; then
cp keys.txt keys/ 2>/dev/null || true
fi
# Apply config overrides
if [ "$MAX_STREAMS" != "0" ]; then
echo "Setting MaxConcurrentStreams=$MAX_STREAMS in provider config..."
python3 -c "
import json
with open('providers/sample.cfg') as f:
cfg = json.load(f)
cfg['MaxConcurrentStreams'] = $MAX_STREAMS
with open('providers/sample.cfg', 'w') as f:
json.dump(cfg, f, indent=4, ensure_ascii=False)
" 2>/dev/null || echo " (failed to patch config continuing with existing value)"
fi
# Build command line
ARGS="-c o11.cfg -p $PORT -b $BIND -headless -stdout -v $VERBOSE"
if [ "$KEEP_FALSE" = "true" ]; then
ARGS="$ARGS -keep=false"
fi
if [ "$HTTPS" = "true" ]; then
ARGS="$ARGS -https"
if [ ! -f "server.crt" ] || [ ! -f "server.key" ]; then
echo "WARNING: -https requested but server.crt/server.key not found"
fi
fi
if [ -n "$ADMIN_USER" ] && [ -n "$ADMIN_PASS" ]; then
ARGS="$ARGS -user $ADMIN_USER -password $ADMIN_PASS"
fi
# Set Go runtime environment
if [ -n "$GOMEMLIMIT" ] && [ "$GOMEMLIMIT" != "0" ]; then
GOMEMLIMIT_BYTES=$(python3 -c "
import re
s = '$GOMEMLIMIT'.strip()
m = re.match(r'^(\d+(?:\.\d+)?)\s*([KMGT]i?B?|B)?$', s, re.I)
if not m:
print(0)
else:
n = float(m.group(1))
unit = (m.group(2) or 'B').upper()
mult = {'B':1, 'KB':1000, 'KIB':1024, 'MB':1000000, 'MIB':1048576,
'GB':1000000000, 'GIB':1073741824, 'TB':1000000000000, 'TIB':1099511627776}
print(int(n * mult.get(unit, 1)))
" 2>/dev/null || echo "0")
if [ "$GOMEMLIMIT_BYTES" -gt 0 ]; then
export GOMEMLIMIT="$GOMEMLIMIT_BYTES"
echo "Go memory limit: $GOMEMLIMIT bytes ($GOMEMLIMIT_BYTES bytes)"
fi
fi
export GOTRACEBACK="${GOTRACEBACK:-0}"
# Kill any existing instance
EXISTING_PID=$(pgrep -f "o11pro.*-p $PORT" 2>/dev/null || true)
if [ -n "$EXISTING_PID" ]; then
echo "Killing existing instance on port $PORT (PID $EXISTING_PID)..."
kill "$EXISTING_PID" 2>/dev/null || true
sleep 2
if kill -0 "$EXISTING_PID" 2>/dev/null; then
kill -9 "$EXISTING_PID" 2>/dev/null || true
sleep 1
fi
fi
# Launch
echo ""
echo "=========================================="
echo " Launching o11pro"
echo "=========================================="
echo " Port: $PORT"
echo " Bind: $BIND"
echo " Verbose: $VERBOSE"
echo " Keep files: false (memory-safe mode)"
echo " Memory cap: ${GOMEMLIMIT:-none}"
echo " Max streams: ${MAX_STREAMS:-unlimited}"
echo " HTTPS: $HTTPS"
echo " HLS Proxy: $HLS_PROXY${HLS_PROXY:+ (port $HLS_PROXY_PORT)}"
echo " Security mon: $MONITOR${MONITOR:+ (port $MONITOR_PORT)}"
echo " Config: o11.cfg + providers/sample.cfg"
echo "=========================================="
echo ""
echo "Service endpoints:"
if [ "$MONITOR" = "true" ]; then
echo " Web UI (monitored): http://$BIND:$MONITOR_PORT (-> o11 :$PORT)"
else
echo " Web UI (direct): http://$BIND:$PORT"
fi
if [ "$HLS_PROXY" = "true" ]; then
echo " HLS Proxy: http://$HLS_PROXY_BIND:$HLS_PROXY_PORT"
echo " Channel manifest: http://$HLS_PROXY_BIND:$HLS_PROXY_PORT/channel/{name}/master.m3u8"
fi
if [ "$MONITOR" = "true" ]; then
echo " Audit log: logs/audit.log"
echo " Alert log: logs/audit_alerts.log"
fi
echo " (use 127.0.0.1 instead of 0.0.0.0 in your browser)"
echo ""
echo "Login: admin / <see temp password in log below>"
echo ""
# FIX: Unified background PID tracking + cleanup (replaces separate handlers)
_BG_PIDS=""
cleanup_all() {
echo ""
echo "Shutting down..."
for pid in $_BG_PIDS; do
kill "$pid" 2>/dev/null || true
done
for pid in $_BG_PIDS; do
wait "$pid" 2>/dev/null || true
done
echo "All processes stopped"
}
if [ "$MONITOR" = "true" ] || [ "$HLS_PROXY" = "true" ]; then
# FIX: Background mode for both HLS_PROXY and MONITOR (were separate)
trap cleanup_all EXIT INT TERM
# Start o11pro in background
echo "Starting o11pro in background..."
"./$BINARY" $ARGS & # FIX: was "$BINARY" (not in PATH)
O11_PID=$!
_BG_PIDS="$O11_PID"
echo "o11pro started (PID $O11_PID)"
# Wait for o11pro to be ready
echo "Waiting for o11pro to be ready on port $PORT..."
for i in $(seq 1 30); do
if python3 -c "import socket; s=socket.socket(); s.settimeout(1); s.connect(('127.0.0.1',$PORT)); s.close()" 2>/dev/null; then
echo "o11pro is ready"
break
fi
if [ $i -eq 30 ]; then
echo "ERROR: o11pro did not start in time"
kill "$O11_PID" 2>/dev/null || true
exit 1
fi
sleep 1
done
# Start HLS Proxy if enabled
if [ "$HLS_PROXY" = "true" ]; then
if [ ! -f "modules/hls_proxy.py" ]; then
echo "WARNING: modules/hls_proxy.py not found skipping HLS proxy"
elif [ ! -f "$HLS_PROXY_CONFIG" ]; then
echo "WARNING: HLS proxy config not found at $HLS_PROXY_CONFIG skipping HLS proxy"
echo " Run setup.sh to auto-generate it from provider config"
else
echo "Starting HLS Proxy on port $HLS_PROXY_PORT..."
python3 modules/hls_proxy.py \
--config "$HLS_PROXY_CONFIG" \
--port "$HLS_PROXY_PORT" \
--bind "$HLS_PROXY_BIND" &
HLS_PID=$!
_BG_PIDS="$_BG_PIDS $HLS_PID"
echo "HLS Proxy started (PID $HLS_PID)"
fi
fi
# Start security monitor if enabled
if [ "$MONITOR" = "true" ]; then
if [ ! -f "modules/monitoring.py" ]; then
echo "ERROR: modules/monitoring.py not found cannot start monitor"
exit 1
fi
# FIX: Build monitor command array with explicit --pid (avoids flaky auto-detection)
# FIX: Log to persistent logs/ directory instead of /tmp
MONITOR_CMD=(
python3 modules/monitoring.py
--proxy-mode
--proxy-port "$MONITOR_PORT"
--target-port "$PORT"
--log "logs/audit.log"
--alerts "logs/audit_alerts.log"
)
if [ -n "$O11_PID" ] && kill -0 "$O11_PID" 2>/dev/null; then
MONITOR_CMD+=(--pid "$O11_PID")
fi
if [ -n "${MONITOR_ARGS:-}" ]; then
MONITOR_CMD+=($MONITOR_ARGS)
fi
echo "Security monitor enabled"
echo " Proxy port: $MONITOR_PORT (-> o11 API :$PORT)"
echo " Process scan: PID $O11_PID"
echo " File watch: keys.txt, providers/, o11.cfg, logs/, hls/"
echo " Audit log: logs/audit.log"
echo " Alert log: logs/audit_alerts.log"
echo ""
echo "Starting security monitor on port $MONITOR_PORT..."
echo "Press Ctrl+C to stop"
echo ""
exec "${MONITOR_CMD[@]}"
else
# No foreground monitor keep the script alive
echo ""
echo "All services running. Press Ctrl+C to stop."
echo ""
wait -n 2>/dev/null || wait
fi
else
# Direct mode: run o11pro directly (no proxies)
echo "Press Ctrl+C to stop"
echo ""
exec "./$BINARY" $ARGS # FIX: was "$BINARY" (not in PATH)
fi
Binary file not shown.
Binary file not shown.
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env python3
import http.server
import http.cookiejar
import urllib.request
import urllib.error
import urllib.parse
import json
import re
import base64
import time
import sys
import os
import argparse # FIX: was missing
import threading
import traceback
import gc
from collections import OrderedDict
from socketserver import ThreadingMixIn
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from concurrent.futures import ThreadPoolExecutor
CONFIG_FILE = '/tmp/o11pro_orig_urls.json'
PROXY_PORT = 9999
PROXY_BIND = '127.0.0.1'
UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
MAX_COOKIES_PER_JAR = 50
MAX_CACHED_OPENERS = 20
MANIFEST_CACHE_SIZE = 100
MAX_THREAD_POOL = 32
GC_INTERVAL_SEC = 60
SEGMENT_STREAM_BUF = 65536
# Cached channel URLs (loaded once)
_channel_urls = {}
_channel_urls_lock = threading.Lock()
def _load_channel_urls():
global _channel_urls
with _channel_urls_lock:
if not _channel_urls:
with open(CONFIG_FILE) as f:
_channel_urls = json.load(f)
log(f"Loaded {len(_channel_urls)} channel URLs")
return _channel_urls
# Bounded CookieJar
class BoundedCookieJar(http.cookiejar.CookieJar):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def set_cookie(self, cookie):
super().set_cookie(cookie)
if len(self) > MAX_COOKIES_PER_JAR:
to_remove = len(self) - MAX_COOKIES_PER_JAR
cookies = list(self)
for c in cookies[:to_remove]:
self.clear(c.domain, c.path, c.name)
# Session/Cookie management
_sessions = {}
_sessions_lock = threading.Lock()
_session_last_access = {}
def _get_jar(domain):
with _sessions_lock:
if domain not in _sessions:
_sessions[domain] = BoundedCookieJar()
_session_last_access[domain] = time.time()
return _sessions[domain]
def _evict_stale_sessions(max_age=3600):
with _sessions_lock:
now = time.time()
stale = [d for d, t in _session_last_access.items() if now - t > max_age]
for d in stale:
del _sessions[d]
del _session_last_access[d]
if stale:
log(f"Evicted {len(stale)} stale sessions, {len(_sessions)} remaining")
# Cached openers (LRU)
_opener_cache = OrderedDict()
_opener_cache_lock = threading.Lock()
def _get_opener(cookie_jar=None):
jar_id = id(cookie_jar) if cookie_jar else 0
with _opener_cache_lock:
if jar_id in _opener_cache:
_opener_cache.move_to_end(jar_id)
return _opener_cache[jar_id]
handlers = []
if cookie_jar:
handlers.append(urllib.request.HTTPCookieProcessor(cookie_jar))
opener = urllib.request.build_opener(*handlers)
with _opener_cache_lock:
_opener_cache[jar_id] = opener
_opener_cache.move_to_end(jar_id)
while len(_opener_cache) > MAX_CACHED_OPENERS:
_opener_cache.popitem(last=False)
return opener
# URL fetching
def fetch_url(url, timeout=15, cookie_jar=None, referer=None):
opener = _get_opener(cookie_jar)
headers = {'User-Agent': UA}
if referer:
headers['Referer'] = referer
req = urllib.request.Request(url, headers=headers)
try:
with opener.open(req, timeout=timeout) as resp:
return resp.read(), resp.status, resp.headers.get('Content-Type', 'application/octet-stream')
except urllib.error.HTTPError as e:
body = e.read() if e.fp else b''
ct = e.headers.get('Content-Type', 'application/octet-stream') if e.headers else 'text/html'
return body, e.code, ct
except (urllib.error.URLError, OSError) as e:
return b'', 0, 'text/plain'
# Manifest URL rewriting
_compiled_uri_re = re.compile(r'URI="([^"]+)"')
def rewrite_manifest(body, base_url):
lines = body.split('\n')
rewritten = []
for line in lines:
stripped = line.strip()
if 'URI="' in stripped and stripped.startswith('#EXT-X-'):
rewritten.append(_compiled_uri_re.sub(
lambda m: f'URI="{_abs(m.group(1), base_url)}"', stripped))
elif stripped and not stripped.startswith('#'):
rewritten.append(_abs(stripped, base_url))
else:
rewritten.append(line)
return '\n'.join(rewritten)
def _abs(url, base):
if url.startswith(f'http://127.0.0.1:{PROXY_PORT}') or url.startswith('data:'):
return url
resolved = url if url.startswith('http') else urllib.parse.urljoin(base, url)
return _to_proxy(resolved, base)
def _to_proxy(full_url, referer):
encoded = base64.urlsafe_b64encode(full_url.encode()).decode().rstrip('=')
route = '/m/' if urlparse(full_url).path.lower().endswith('.m3u8') else '/s/'
proxy_url = f"http://127.0.0.1:{PROXY_PORT}{route}{encoded}"
if referer:
ref_enc = base64.urlsafe_b64encode(referer.encode()).decode().rstrip('=')
proxy_url += f"?r={ref_enc}"
return proxy_url
def _from_proxy(encoded):
pad = 4 - len(encoded) % 4
if pad != 4:
encoded += '=' * pad
return base64.urlsafe_b64decode(encoded.encode()).decode()
def _get_referer(query_string):
if not query_string:
return None
qs = parse_qs(query_string)
if 'r' in qs:
return _from_proxy(qs['r'][0])
return None
# Stats
_stats = {
'requests': 0,
'manifests': 0,
'segments': 0,
'bytes_sent': 0,
'errors': 0,
}
_stats_lock = threading.Lock()
# Request Handler
class RobustHandler(BaseHTTPRequestHandler):
def do_GET(self):
with _stats_lock:
_stats['requests'] += 1
parsed = urlparse(self.path)
path = parsed.path
query = parsed.query
try:
if path.startswith('/channel/'):
self._handle_channel(path, query)
elif path.startswith('/m/'):
self._handle_manifest(path, query)
elif path.startswith('/s/'):
self._handle_segment(path, query)
else:
self._err(404, 'Not found')
except Exception as e:
with _stats_lock:
_stats['errors'] += 1
try:
self._err(500, str(e))
except:
pass
def _handle_channel(self, path, query):
parts = path.split('/')
if len(parts) >= 4 and parts[3] == 'master.m3u8':
name = urllib.parse.unquote(parts[2])
urls = _load_channel_urls()
if name in urls:
url = urls[name]
q = query
if q:
url += ('&' if '?' in url else '?') + q
jar = _get_jar(urlparse(url).netloc)
body, st, ct = fetch_url(url, cookie_jar=jar)
if st == 200:
with _stats_lock:
_stats['manifests'] += 1
rw = rewrite_manifest(body.decode('utf-8', 'replace'), url)
self._ok('application/vnd.apple.mpegurl', rw.encode())
else:
self._err(502, f"Upstream {st}")
else:
self._err(404, "Channel not found")
else:
self._err(400, "Bad channel path")
def _handle_manifest(self, path, query):
target = _from_proxy(path[3:])
referer = _get_referer(query)
jar = _get_jar(urlparse(target).netloc)
body, st, ct = fetch_url(target, cookie_jar=jar, referer=referer)
if st == 200:
with _stats_lock:
_stats['manifests'] += 1
rw = rewrite_manifest(body.decode('utf-8', 'replace'), target)
self._ok('application/vnd.apple.mpegurl', rw.encode())
else:
self._err(502, f"Upstream {st}")
def _handle_segment(self, path, query):
target = _from_proxy(path[3:])
if target.startswith('data:'):
cp = target.find(',')
if cp >= 0:
meta, payload = target[5:cp], target[cp + 1:]
b = base64.b64decode(payload) if 'base64' in meta else urllib.parse.unquote_to_bytes(payload)
self._ok('application/octet-stream', b)
return
referer = _get_referer(query)
jar = _get_jar(urlparse(target).netloc)
headers = {'User-Agent': UA}
if referer:
headers['Referer'] = referer
opener = _get_opener(jar)
req = urllib.request.Request(target, headers=headers)
try:
with opener.open(req, timeout=30) as resp:
ct = resp.headers.get('Content-Type', 'application/octet-stream')
content_length = resp.headers.get('Content-Length')
self.send_response(200)
self.send_header('Content-Type', ct)
if content_length:
self.send_header('Content-Length', content_length)
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
total_sent = 0
while True:
chunk = resp.read(SEGMENT_STREAM_BUF)
if not chunk:
break
try:
self.wfile.write(chunk)
total_sent += len(chunk)
except (BrokenPipeError, ConnectionResetError, OSError):
break
with _stats_lock:
_stats['segments'] += 1
_stats['bytes_sent'] += total_sent
except urllib.error.HTTPError as e:
self._err(502, f'Upstream {e.code}')
except (urllib.error.URLError, OSError) as e:
self._err(502, f'Fetch error: {type(e).__name__}')
except Exception as e:
try:
self._err(502, str(e))
except:
pass
def _ok(self, ct, body):
try:
self.send_response(200)
self.send_header('Content-Type', ct)
self.send_header('Content-Length', str(len(body)))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(body)
with _stats_lock:
_stats['bytes_sent'] += len(body)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
def _err(self, code, msg):
try:
self.send_response(code)
self.send_header('Content-Type', 'text/plain')
body = msg.encode()
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
except (BrokenPipeError, ConnectionResetError, OSError):
pass
def log_message(self, *a):
pass
def handle(self):
try:
BaseHTTPRequestHandler.handle(self)
except (BrokenPipeError, ConnectionResetError, OSError, ValueError):
pass
except Exception:
pass
# Server with bounded thread pool
class BoundedThreadedServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
allow_reuse_address = True
request_queue_size = 128
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._thread_pool = ThreadPoolExecutor(max_workers=MAX_THREAD_POOL)
def process_request(self, request, client_address):
self._thread_pool.submit(self._process_request_thread, request, client_address)
def _process_request_thread(self, request, client_address):
try:
self.finish_request(request, client_address)
except Exception:
self.handle_error(request, client_address)
finally:
self.shutdown_request(request)
def handle_error(self, request, client_address):
pass
# Periodic maintenance
def _maintenance_loop():
while True:
time.sleep(GC_INTERVAL_SEC)
collected = gc.collect()
_evict_stale_sessions()
pid = os.getpid()
rss = _get_rss_kb(pid)
with _stats_lock:
s = dict(_stats)
log(f"STATS: rss={rss}KB reqs={s['requests']} manifests={s['manifests']} "
f"segments={s['segments']} bytes={s['bytes_sent']//1024//1024}MB "
f"errors={s['errors']} sessions={len(_sessions)} openers={len(_opener_cache)} "
f"gc_collected={collected}")
def _get_rss_kb(pid):
try:
with open(f'/proc/{pid}/status') as f:
for line in f:
if line.startswith('VmRSS:'):
return int(line.split()[1])
except:
return 0
def log(msg):
sys.stderr.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
sys.stderr.flush()
# Main
if __name__ == '__main__':
# FIX: was hardcoded, now accepts CLI arguments
parser = argparse.ArgumentParser(description='HLS Proxy for o11pro (memory-leak fixed)')
parser.add_argument('--config', default=CONFIG_FILE,
help=f'Path to channel URLs JSON (default: {CONFIG_FILE})')
parser.add_argument('--port', type=int, default=PROXY_PORT,
help=f'Proxy listen port (default: {PROXY_PORT})')
parser.add_argument('--bind', default=PROXY_BIND,
help=f'Proxy bind address (default: {PROXY_BIND})')
args = parser.parse_args()
CONFIG_FILE = args.config
PROXY_PORT = args.port
PROXY_BIND = args.bind
_load_channel_urls()
assert _compiled_uri_re is not None
maint = threading.Thread(target=_maintenance_loop, daemon=True)
maint.start()
print(f"HLS Proxy on {PROXY_BIND}:{PROXY_PORT} (memory-optimized)", flush=True)
print(f" Config: {CONFIG_FILE}", flush=True)
print(f" Channels: {len(_channel_urls)}", flush=True)
print(f" Thread pool: {MAX_THREAD_POOL} workers", flush=True)
print(f" Cookie limit: {MAX_COOKIES_PER_JAR}/jar", flush=True)
print(f" Streaming buffer: {SEGMENT_STREAM_BUF} bytes", flush=True)
print(f" GC interval: {GC_INTERVAL_SEC}s", flush=True)
print(f"", flush=True)
print(f" Routes:", flush=True)
print(f" /channel/{{name}}/master.m3u8 - Channel master manifest", flush=True)
print(f" /m/{{b64_url}} - Manifest fetch+rewrite", flush=True)
print(f" /s/{{b64_url}} - Segment stream", flush=True)
server = BoundedThreadedServer((PROXY_BIND, PROXY_PORT), RobustHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down...")
server.shutdown()
+882
View File
@@ -0,0 +1,882 @@
#!/usr/bin/env python3
import os
import sys
import re
import time
import json
import socket
import struct
import signal
import argparse
import threading
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from collections import defaultdict, deque
import http.client
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
VERSION = "1.0.0"
DEFAULT_PID_FILE = "/tmp/o11_pid"
DEFAULT_AUDIT_LOG = "audit.log"
DEFAULT_ALERT_LOG = "audit_alerts.log"
DEFAULT_PROXY_PORT = 19998
SCAN_INTERVAL = 2.0
EXFIL_THRESHOLD = 100 * 1024 * 1024
MAX_LOG_SIZE = 100 * 1024 * 1024
MAX_LOG_FILES = 5
ATTACK_SIGNATURES = {
"command_injection": [
# Shell metacharacters
(r'[;|&`$\(\)]', "shell metacharacter"),
(r'\$\([^)]*\)', "command substitution $()"),
(r'`[^`]*`', "backtick command substitution"),
(r'\|\s*(sh|bash|nc|ncat|python|perl|ruby)\b', "pipe to interpreter"),
(r';\s*(sh|bash|nc|ncat|curl|wget)\b', "semicolon then shell command"),
(r'&&\s*(sh|bash|nc|ncat|curl|wget)\b', "&& then shell command"),
(r'\|\|\s*(sh|bash|nc|ncat|curl|wget)\b', "|| then shell command"),
(r'\bexec\s*\(', "exec() call"),
(r'\bsystem\s*\(', "system() call"),
(r'\bpopen\s*\(', "popen() call"),
(r'\beval\s*\(', "eval() call"),
# Common injection payloads
(r';\s*cat\s+/etc/passwd', "etc/passwd via cat"),
(r';\s*cat\s+/etc/shadow', "etc/shadow via cat"),
(r';\s*id\s*;', "id command injection"),
(r';\s*whoami\s*;', "whoami command injection"),
(r';\s*uname\s+-a', "uname command injection"),
(r';\s*wget\s+', "wget download injection"),
(r';\s*curl\s+', "curl download injection"),
(r';\s*nc\s+', "netcat injection"),
(r'/bin/sh\s+-c', "sh -c invocation"),
(r'/bin/bash\s+-c', "bash -c invocation"),
(r'bash\s+-i\s+>&', "reverse shell bash -i"),
(r'/dev/tcp/', "bash /dev/tcp reverse shell"),
(r'/dev/udp/', "bash /dev/udp"),
(r'mkfifo\s+/tmp/', "mkfifo reverse shell setup"),
],
"sql_injection": [
(r"'\s*OR\s+'?1'?\s*=\s*'?1", "OR 1=1 classic SQLi"),
(r"'\s*OR\s+'?1'?\s*=\s*'?1'?\s*--", "OR 1=1 with comment"),
(r"'\s*OR\s+'?1'?\s*=\s*'?1'?\s*#", "OR 1=1 with hash comment"),
(r"'\s*OR\s+'?1'?\s*=\s*'?1'?\s*/\*", "OR 1=1 with block comment"),
(r"\bUNION\s+SELECT\b", "UNION SELECT"),
(r"\bUNION\s+ALL\s+SELECT\b", "UNION ALL SELECT"),
(r"--\s*$", "SQL comment at end"),
(r"--\s*;", "SQL comment semicolon"),
(r"/\*.*\*/", "SQL block comment"),
(r"\bxp_cmdshell\b", "MSSQL xp_cmdshell"),
(r"\bsp_executesql\b", "MSSQL sp_executesql"),
(r"\bDROP\s+TABLE\b", "DROP TABLE"),
(r"\bDROP\s+DATABASE\b", "DROP DATABASE"),
(r"\bINSERT\s+INTO\b", "INSERT INTO"),
(r"\bDELETE\s+FROM\b", "DELETE FROM"),
(r"\bUPDATE\s+.*\bSET\b", "UPDATE SET"),
(r"\bSELECT\s+.*\bFROM\s+information_schema\b", "information_schema access"),
(r"\bSELECT\s+.*\bFROM\s+mysql\.user\b", "mysql.user access"),
(r"\bINTO\s+OUTFILE\b", "MySQL INTO OUTFILE"),
(r"\bINTO\s+DUMPFILE\b", "MySQL INTO DUMPFILE"),
(r"\bLOAD_FILE\s*\(", "MySQL LOAD_FILE"),
(r"\bCONCAT\s*\(", "MySQL CONCAT"),
(r"\bSLEEP\s*\(\s*\d+\s*\)", "SLEEP() time-based SQLi"),
(r"\bBENCHMARK\s*\(", "BENCHMARK() time-based SQLi"),
(r"\bWAITFOR\s+DELAY\b", "MSSQL WAITFOR DELAY"),
(r"'\s*;\s*", "semicolon after quote"),
],
"path_traversal": [
(r'\.\./', "../ directory traversal"),
(r'\.\.\\', "..\\ directory traversal"),
(r'%2e%2e%2f', "%2e%2e%2f encoded traversal"),
(r'%2e%2e/', "%2e%2e/ encoded traversal"),
(r'..%2f', "..%2f encoded traversal"),
(r'..%5c', "..%5c encoded traversal"),
(r'%2e%2e%5c', "%2e%2e%5c encoded traversal"),
(r'\.\.%c0%af', "UTF-8 overlong traversal"),
(r'\.\.%c1%9c', "UTF-8 overlong traversal"),
(r'....//', "double dot slash bypass"),
(r'....\\\\', "double dot backslash bypass"),
(r'\x00', "null byte injection"),
(r'%00', "encoded null byte"),
(r'/etc/passwd', "/etc/passwd access"),
(r'/etc/shadow', "/etc/shadow access"),
(r'/etc/sudoers', "/etc/sudoers access"),
(r'/root/\.', "/root/ directory access"),
(r'/home/[^/]+/\.ssh', "SSH directory access"),
(r'authorized_keys', "authorized_keys access"),
(r'/proc/self/environ', "/proc/self/environ access"),
(r'/proc/self/cmdline', "/proc/self/cmdline access"),
],
"xss": [
(r'<script', "<script> tag"),
(r'</script>', "</script> tag"),
(r'javascript:', "javascript: protocol"),
(r'onerror\s*=', "onerror event handler"),
(r'onload\s*=', "onload event handler"),
(r'onclick\s*=', "onclick event handler"),
(r'onmouseover\s*=', "onmouseover event handler"),
(r'onfocus\s*=', "onfocus event handler"),
(r'onblur\s*=', "onblur event handler"),
(r'<iframe', "<iframe> tag"),
(r'<object', "<object> tag"),
(r'<embed', "<embed> tag"),
(r'<svg\s+onload', "<svg onload> XSS"),
(r'<img\s+[^>]*onerror', "<img onerror> XSS"),
(r'document\.cookie', "document.cookie access"),
(r'document\.location', "document.location access"),
(r'window\.location', "window.location access"),
(r'eval\s*\(', "eval() XSS"),
(r'String\.fromCharCode', "String.fromCharCode obfuscation"),
(r'\\x[0-9a-f]{2}', "hex escape sequence"),
],
"ssrf": [
(r'http://127\.0\.0\.1', "localhost HTTP access"),
(r'http://localhost', "localhost HTTP access"),
(r'http://0\.0\.0\.0', "0.0.0.0 HTTP access"),
(r'http://\[::1\]', "IPv6 localhost access"),
(r'http://169\.254\.169\.254', "AWS metadata endpoint"),
(r'http://metadata\.google\.internal', "GCP metadata endpoint"),
(r'http://169\.254\.169\.254/computeMetadata', "GCP metadata v1"),
(r'http://100\.100\.100\.200', "Alibaba Cloud metadata"),
(r'http://metadata\.azure\.com', "Azure metadata endpoint"),
(r'file://', "file:// protocol"),
(r'gopher://', "gopher:// protocol"),
(r'dict://', "dict:// protocol"),
(r'ftp://', "ftp:// protocol"),
(r'smb://', "smb:// protocol"),
(r'ldap://', "ldap:// protocol"),
(r'http://10\.\d+\.\d+\.\d+', "private 10.x.x.x access"),
(r'http://192\.168\.\d+\.\d+', "private 192.168.x.x access"),
(r'http://172\.(1[6-9]|2[0-9]|3[01])\.', "private 172.16-31.x.x access"),
],
"credential_exfil": [
(r'Bearer\s+[A-Za-z0-9._-]{20,}', "Bearer token in request"),
(r'api[_-]?key\s*[=:]\s*["\']?[A-Za-z0-9]{16,}', "API key"),
(r'authorization\s*:\s*bearer', "Authorization header"),
(r'x-api-key\s*:', "X-API-Key header"),
(r'password\s*[=:]\s*["\']?[^\s"\']{8,}', "password field"),
(r'passwd\s*[=:]\s*["\']?[^\s"\']{8,}', "passwd field"),
(r'secret\s*[=:]\s*["\']?[A-Za-z0-9]{16,}', "secret field"),
(r'token\s*[=:]\s*["\']?[A-Za-z0-9]{16,}', "token field"),
(r'jwt\s*[=:]\s*["\']?ey[A-Za-z0-9._-]+', "JWT token"),
(r'BEGIN\s+[A-Z\s]+PRIVATE\s+KEY', "private key in traffic"),
(r'AKIA[0-9A-Z]{16}', "AWS access key ID"),
(r'gh[pu]_[A-Za-z0-9]{36,}', "GitHub token"),
(r'xox[baprs]-[A-Za-z0-9-]+', "Slack token"),
],
"reverse_shell": [
(r'bash\s+-i\s+>&\s*/dev/tcp/', "bash reverse shell /dev/tcp"),
(r'sh\s+-i\s+>&\s*/dev/tcp/', "sh reverse shell /dev/tcp"),
(r'nc\s+-e\s+/bin/', "netcat -e reverse shell"),
(r'ncat\s+-e\s+/bin/', "ncat -e reverse shell"),
(r'python\s+-c\s+.*socket', "python reverse shell"),
(r'perl\s+-e\s+.*socket', "perl reverse shell"),
(r'ruby\s+-e\s+.*socket', "ruby reverse shell"),
(r'php\s+-r\s+.*socket', "php reverse shell"),
(r'/dev/tcp/\d+\.\d+\.\d+\.\d+/', "/dev/tcp IP address"),
(r'/dev/udp/\d+\.\d+\.\d+\.\d+/', "/dev/udp IP address"),
(r'mknod\s+/tmp/', "mknod reverse shell"),
(r'mkfifo\s+/tmp/', "mkfifo reverse shell"),
],
"suspicious_file_access": [
r'/etc/passwd',
r'/etc/shadow',
r'/etc/sudoers',
r'/etc/ssh/',
r'/root/\.ssh/',
r'/home/[^/]+/\.ssh/',
r'authorized_keys',
r'/proc/self/environ',
r'/proc/self/cmdline',
r'/proc/self/mem',
r'/boot/grub',
r'/var/log/auth\.log',
r'/var/log/secure',
r'\.bash_history',
r'\.mysql_history',
r'\.psql_history',
r'/tmp/\.\w+', # hidden files in /tmp
r'/dev/shm/[^/]', # files in /dev/shm (often used by malware)
],
"suspicious_process": [
r'^/bin/sh\b',
r'^/bin/bash\b',
r'^sh\b',
r'^bash\b',
r'^nc\b',
r'^ncat\b',
r'^netcat\b',
r'^telnet\b',
r'^ssh\b',
r'^scp\b',
r'^curl\b',
r'^wget\b',
r'^python\s+-c\b',
r'^perl\s+-e\b',
r'^ruby\s+-e\b',
r'^chmod\s+\+x\b',
r'^chown\s+root\b',
r'^kill\s+-9\b',
r'^pkill\b',
],
}
# Suspicious outbound IPs (known bad ranges) - empty by default
# Add specific IPs to watch for
SUSPICIOUS_IPS = set()
# Expected outbound ports (everything else is suspicious)
EXPECTED_PORTS = {80, 443, 89, 53}
# Expected CDN domains (whitelist for outbound connections)
EXPECTED_DOMAINS = {
'akamaized.net', 'akamai.net', 'rogers.com', 'nordvpn.com',
'surfshark.com', 'movetv.com', 'sling.com', 'pcdn03.cssott.com',
'w3.org', 'golang.org', 'github.com', 'momentjs.com',
'netskrt.live.pv-cdn.net', 'lpnba.akamaized.net',
'qp-pldt-live-bpk-02-prod.akamaized.net', 'g001-live-us-cmaf-prd-fy.pcdn03.cssott.com',
'live-d-01-rogers-cc-prd.akamaized.net', 'live-d-02-rogers-uw-prd.akamaized.net',
'p-cdn1-a-cg14-linear-cbd46b77.movetv.com',
'p-cdn4-a-cg14-linear-cbd46b77.movetv.com',
}
class AuditLogger:
"""Thread-safe audit logger with rotation."""
def __init__(self, log_path, alert_path, max_size=MAX_LOG_SIZE, max_files=MAX_LOG_FILES):
self.log_path = Path(log_path)
self.alert_path = Path(alert_path)
self.max_size = max_size
self.max_files = max_files
self.lock = threading.Lock()
self._ensure_dir()
def _ensure_dir(self):
self.log_path.parent.mkdir(parents=True, exist_ok=True)
self.alert_path.parent.mkdir(parents=True, exist_ok=True)
def _rotate(self, path):
if not path.exists() or path.stat().st_size < self.max_size:
return
for i in range(self.max_files - 1, 0, -1):
old = path.with_suffix(f'.{i}{path.suffix}')
new = path.with_suffix(f'.{i+1}{path.suffix}')
if old.exists():
old.rename(new)
path.rename(path.with_suffix(f'.1{path.suffix}'))
def log(self, event_type, severity, source, details, raw_data=None):
"""Log a security event."""
timestamp = datetime.now(timezone.utc).isoformat()
entry = {
"timestamp": timestamp,
"type": event_type,
"severity": severity, # INFO, LOW, MEDIUM, HIGH, CRITICAL
"source": source, # http, proc, net, file, child
"details": details,
}
if raw_data:
entry["raw"] = raw_data[:1000] # cap raw data
line = json.dumps(entry, ensure_ascii=False)
with self.lock:
self._rotate(self.log_path)
with open(self.log_path, 'a', encoding='utf-8') as f:
f.write(line + '\n')
# High-severity events also go to alerts log
if severity in ('HIGH', 'CRITICAL'):
self._rotate(self.alert_path)
with open(self.alert_path, 'a', encoding='utf-8') as f:
f.write(line + '\n')
# Print to stdout with color
color = {
'INFO': '\033[37m',
'LOW': '\033[36m',
'MEDIUM': '\033[33m',
'HIGH': '\033[31m',
'CRITICAL': '\033[1;31m',
}.get(severity, '\033[0m')
reset = '\033[0m'
print(f"{color}[{timestamp}] {severity:8s} {event_type:25s} {source:8s}{reset} {details}")
return entry
class AttackDetector:
"""Detect attacks in strings and URLs."""
def __init__(self, logger):
self.logger = logger
self.compiled = {}
for category, patterns in ATTACK_SIGNATURES.items():
compiled = []
for p in patterns:
if isinstance(p, tuple):
compiled.append((re.compile(p[0], re.IGNORECASE), p[1]))
else:
# bare pattern (no description) used by suspicious_file_access/process
compiled.append((re.compile(p, re.IGNORECASE), category))
self.compiled[category] = compiled
def scan_string(self, text, source='http', context=''):
"""Scan a string for attack patterns. Returns list of matches."""
if not text:
return []
text_str = text if isinstance(text, str) else text.decode('utf-8', errors='replace')
matches = []
for category, patterns in self.compiled.items():
if category in ('suspicious_file_access', 'suspicious_process'):
continue # these are handled separately
for regex, desc in patterns:
for m in regex.finditer(text_str):
severity = self._severity(category)
details = f"{desc}: matched '{m.group()[:80]}' in {context}"
self.logger.log(f"attack_{category}", severity, source, details, text_str[:500])
matches.append((category, desc, m.group()))
return matches
def _severity(self, category):
severities = {
'command_injection': 'CRITICAL',
'sql_injection': 'HIGH',
'path_traversal': 'HIGH',
'xss': 'MEDIUM',
'ssrf': 'HIGH',
'credential_exfil': 'HIGH',
'reverse_shell': 'CRITICAL',
}
return severities.get(category, 'MEDIUM')
def scan_url(self, url, source='http'):
"""Scan a URL for attacks."""
return self.scan_string(url, source, f'URL: {url[:100]}')
def scan_headers(self, headers, source='http'):
"""Scan HTTP headers for attacks."""
for key, value in headers.items():
self.scan_string(value, source, f'header {key}')
def scan_body(self, body, source='http'):
"""Scan HTTP body for attacks."""
if isinstance(body, bytes):
try:
body = body.decode('utf-8', errors='replace')
except:
return []
return self.scan_string(body, source, 'request body')
class ProcessMonitor:
"""Monitor a process via /proc filesystem."""
def __init__(self, pid, logger, detector):
self.pid = pid
self.logger = logger
self.detector = detector
self.prev_io = None
self.prev_children = set()
self.prev_fds = set()
self.prev_connections = set()
self.running = True
def _read_file(self, path):
try:
with open(path) as f:
return f.read()
except (FileNotFoundError, ProcessLookupError, PermissionError):
return None
def _get_children(self):
"""Get child process PIDs."""
children = set()
try:
task_dirs = os.listdir(f'/proc/{self.pid}/task')
except (FileNotFoundError, ProcessLookupError):
return children
for tid in task_dirs:
content = self._read_file(f'/proc/{self.pid}/task/{tid}/children')
if content:
for pid_str in content.split():
try:
children.add(int(pid_str))
except ValueError:
pass
return children
def _get_fds(self):
"""Get open file descriptors."""
fds = set()
try:
fd_list = os.listdir(f'/proc/{self.pid}/fd')
except (FileNotFoundError, ProcessLookupError, PermissionError):
return fds
for fd in fd_list:
try:
link = os.readlink(f'/proc/{self.pid}/fd/{fd}')
fds.add((fd, link))
except (OSError, PermissionError):
pass
return fds
def _get_connections(self):
"""Get network connections from /proc/<pid>/net/tcp and tcp6."""
conns = set()
for proto in ('tcp', 'tcp6'):
content = self._read_file(f'/proc/{self.pid}/net/{proto}')
if not content:
continue
for line in content.split('\n')[1:]: # skip header
parts = line.split()
if len(parts) < 4:
continue
local = parts[1]
remote = parts[2]
state = parts[3]
if state != '01': # ESTABLISHED
continue
# Parse address:port
try:
if proto == 'tcp':
# IPv4: hex IP:port
ip_hex, port_hex = local.split(':')
ip = socket.inet_ntoa(struct.pack('<I', int(ip_hex, 16)))
port = int(port_hex, 16)
r_ip_hex, r_port_hex = remote.split(':')
r_ip = socket.inet_ntoa(struct.pack('<I', int(r_ip_hex, 16)))
r_port = int(r_port_hex, 16)
else:
# IPv6: 32 hex chars : port
ip_hex, port_hex = local.split(':')
ip_bytes = bytes.fromhex(ip_hex)
ip = socket.inet_ntop(socket.AF_INET6, ip_bytes)
port = int(port_hex, 16)
r_ip_hex, r_port_hex = remote.split(':')
r_ip_bytes = bytes.fromhex(r_ip_hex)
r_ip = socket.inet_ntop(socket.AF_INET6, r_ip_bytes)
r_port = int(r_port_hex, 16)
conns.add((r_ip, r_port, proto))
except (ValueError, struct.error, OSError):
pass
return conns
def _get_io(self):
"""Get process I/O stats."""
content = self._read_file(f'/proc/{self.pid}/io')
if not content:
return None
io = {}
for line in content.split('\n'):
if ':' in line:
key, _, val = line.partition(':')
try:
io[key.strip()] = int(val.strip())
except ValueError:
pass
return io
def _get_child_cmdline(self, child_pid):
"""Get command line of a child process."""
content = self._read_file(f'/proc/{child_pid}/cmdline')
if not content:
return ''
return content.replace('\x00', ' ').strip()
def check_suspicious_files(self, fds):
"""Check open FDs for suspicious file access."""
for fd, link in fds:
for pattern in ATTACK_SIGNATURES['suspicious_file_access']:
if re.search(pattern, link, re.IGNORECASE):
self.logger.log(
'suspicious_file_access', 'HIGH', 'file',
f'Process opened suspicious file: {link} (fd={fd})'
)
def check_suspicious_children(self, children):
"""Check for suspicious child processes."""
for child_pid in children - self.prev_children:
cmdline = self._get_child_cmdline(child_pid)
if not cmdline:
continue
for pattern in ATTACK_SIGNATURES['suspicious_process']:
if re.match(pattern, cmdline, re.IGNORECASE):
self.logger.log(
'suspicious_process', 'CRITICAL', 'child',
f'Suspicious child process spawned: PID={child_pid} cmd={cmdline[:200]}'
)
# Log all new child processes
self.logger.log(
'child_process', 'INFO', 'child',
f'New child process: PID={child_pid} cmd={cmdline[:200]}'
)
def check_connections(self, conns):
"""Check for suspicious network connections."""
for ip, port, proto in conns - self.prev_connections:
# Check suspicious IPs
if ip in SUSPICIOUS_IPS:
self.logger.log(
'suspicious_connection', 'HIGH', 'net',
f'Connection to known-suspicious IP: {ip}:{port} ({proto})'
)
# Check unexpected ports
if port not in EXPECTED_PORTS:
self.logger.log(
'unexpected_port', 'MEDIUM', 'net',
f'Connection to unexpected port: {ip}:{port} ({proto})'
)
# Check for private IP access (potential SSRF)
try:
parts = ip.split('.')
if len(parts) == 4:
first = int(parts[0])
if first == 10 or (first == 172 and 16 <= int(parts[1]) <= 31) or \
(first == 192 and int(parts[1]) == 168) or ip == '127.0.0.1':
self.logger.log(
'ssrf_internal', 'HIGH', 'net',
f'Connection to internal IP: {ip}:{port} ({proto})'
)
except (ValueError, IndexError):
pass
# Log all new connections
self.logger.log(
'connection', 'INFO', 'net',
f'New connection: {ip}:{port} ({proto})'
)
def check_exfil(self, io):
"""Check for high-volume data transfer (exfiltration)."""
if not self.prev_io or not io:
return
bytes_sent_delta = io.get('write_bytes', 0) - self.prev_io.get('write_bytes', 0)
if bytes_sent_delta > EXFIL_THRESHOLD:
self.logger.log(
'potential_exfil', 'HIGH', 'net',
f'High outbound transfer: {bytes_sent_delta / 1024 / 1024:.1f} MB in {SCAN_INTERVAL}s interval'
)
def scan(self):
"""Run one scan cycle."""
if not os.path.exists(f'/proc/{self.pid}'):
self.logger.log('process_exit', 'HIGH', 'proc', f'Process {self.pid} no longer exists')
self.running = False
return
children = self._get_children()
fds = self._get_fds()
conns = self._get_connections()
io = self._get_io()
self.check_suspicious_files(fds)
self.check_suspicious_children(children)
self.check_connections(conns)
self.check_exfil(io)
self.prev_children = children
self.prev_fds = fds
self.prev_connections = conns
self.prev_io = io
def run(self):
"""Main monitoring loop."""
self.logger.log('monitor_start', 'INFO', 'proc', f'Started monitoring PID {self.pid}')
while self.running:
try:
self.scan()
except Exception as e:
self.logger.log('monitor_error', 'LOW', 'proc', f'Scan error: {e}')
time.sleep(SCAN_INTERVAL)
# HTTP Proxy
class ProxyRequestHandler(BaseHTTPRequestHandler):
"""HTTP proxy handler that scans all requests for attacks."""
detector = None
logger = None
target_host = '127.0.0.1'
target_port = 19999
def log_message(self, format, *args):
pass # suppress default logging
def _forward(self, method):
"""Forward request to target and scan for attacks."""
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length) if content_length > 0 else b''
self.logger.log('http_request', 'INFO', 'http',
f'{method} {self.path} from {self.client_address[0]}')
self.detector.scan_url(self.path, 'http')
self.detector.scan_headers(dict(self.headers), 'http')
if body:
self.detector.scan_body(body, 'http')
try:
conn = http.client.HTTPConnection(self.target_host, self.target_port, timeout=30)
headers = dict(self.headers)
headers['Host'] = f'{self.target_host}:{self.target_port}'
conn.request(method, self.path, body=body, headers=headers)
response = conn.getresponse()
resp_body = response.read()
self.detector.scan_body(resp_body, 'http_resp')
self.send_response(response.status)
for key, val in response.getheaders():
if key.lower() not in ('transfer-encoding', 'connection'):
self.send_header(key, val)
self.send_header('Content-Length', str(len(resp_body)))
self.end_headers()
self.wfile.write(resp_body)
conn.close()
except Exception as e:
self.logger.log('proxy_error', 'MEDIUM', 'http', f'Forward error: {e}')
self.send_response(502)
self.end_headers()
self.wfile.write(b'Bad Gateway')
def do_GET(self):
self._forward('GET')
def do_POST(self):
self._forward('POST')
def do_PUT(self):
self._forward('PUT')
def do_DELETE(self):
self._forward('DELETE')
def do_PATCH(self):
self._forward('PATCH')
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With')
self.end_headers()
class ThreadedProxyServer(ThreadingMixIn, HTTPServer):
"""Threaded HTTP proxy server for concurrent request handling."""
daemon_threads = True
allow_reuse_address = True
def run_proxy(port, target_host, target_port, detector, logger):
"""Run the HTTP proxy server."""
ProxyRequestHandler.detector = detector
ProxyRequestHandler.logger = logger
ProxyRequestHandler.target_host = target_host
ProxyRequestHandler.target_port = target_port
server = ThreadedProxyServer(('0.0.0.0', port), ProxyRequestHandler)
logger.log('proxy_start', 'INFO', 'http',
f'Proxy listening on 0.0.0.0:{port}, forwarding to {target_host}:{target_port}')
server.serve_forever()
# File watcher
class FileWatcher:
"""Watch key files for unauthorized changes."""
def __init__(self, logger, watch_paths=None):
self.logger = logger
self.watch_paths = watch_paths or [
'keys.txt',
'providers/sample.cfg',
'o11.cfg',
'logs/',
'hls/',
]
self.prev_state = {}
def _scan_path(self, path):
"""Get current state of a path (file hash or dir listing)."""
import hashlib
if os.path.isfile(path):
try:
with open(path, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
except (PermissionError, FileNotFoundError):
return None
elif os.path.isdir(path):
result = {}
try:
for entry in os.listdir(path):
full = os.path.join(path, entry)
if os.path.isfile(full):
try:
with open(full, 'rb') as f:
result[entry] = hashlib.md5(f.read()).hexdigest()
except (PermissionError, FileNotFoundError):
pass
elif os.path.isdir(full):
result[entry] = 'dir'
except (PermissionError, FileNotFoundError):
pass
return result
return None
def scan(self):
"""Scan watched paths for changes."""
for path in self.watch_paths:
current = self._scan_path(path)
if path not in self.prev_state:
self.prev_state[path] = current
continue
if current != self.prev_state[path]:
if os.path.isfile(path):
self.logger.log('file_change', 'MEDIUM', 'file',
f'File changed: {path}')
elif os.path.isdir(path):
old = self.prev_state[path] or {}
new = current or {}
for f in set(new.keys()) - set(old.keys()):
self.logger.log('file_create', 'LOW', 'file',
f'New file in {path}: {f}')
for f in set(old.keys()) - set(new.keys()):
self.logger.log('file_delete', 'LOW', 'file',
f'File deleted from {path}: {f}')
for f in set(new.keys()) & set(old.keys()):
if old[f] != new[f] and old[f] != 'dir' and new[f] != 'dir':
self.logger.log('file_modify', 'MEDIUM', 'file',
f'File modified in {path}: {f}')
self.prev_state[path] = current
def run(self):
while True:
try:
self.scan()
except Exception as e:
self.logger.log('watcher_error', 'LOW', 'file', f'Watch error: {e}')
time.sleep(5)
# Main
def find_o11_pid():
"""Auto-detect the o11 PID."""
name_patterns = ['o11pro', 'o11pro']
for pattern in name_patterns:
try:
result = subprocess.run(['pgrep', '-f', pattern], capture_output=True, text=True)
if result.stdout.strip():
pids = result.stdout.strip().split('\n')
if pids:
return int(pids[0])
except (FileNotFoundError, ValueError):
pass
for pid_file in (DEFAULT_PID_FILE, '/tmp/o11e2e/pid'):
try:
with open(pid_file) as f:
pid = int(f.read().strip())
if os.path.exists(f'/proc/{pid}'):
return pid
except (FileNotFoundError, ValueError):
pass
for entry in os.listdir('/proc'):
if not entry.isdigit():
continue
try:
with open(f'/proc/{entry}/cmdline', 'rb') as f:
cmdline = f.read().decode('utf-8', errors='replace')
for pattern in name_patterns:
if pattern in cmdline:
return int(entry)
except (FileNotFoundError, PermissionError):
pass
return None
def main():
parser = argparse.ArgumentParser(description='Security monitor for o11pro')
parser.add_argument('--pid', type=int, help='PID of o11 process to monitor (auto-detected if omitted)')
parser.add_argument('--log', default=DEFAULT_AUDIT_LOG, help='Audit log file path')
parser.add_argument('--alerts', default=DEFAULT_ALERT_LOG, help='Alerts log file path (HIGH/CRITICAL only)')
parser.add_argument('--proxy-mode', action='store_true', help='Run as HTTP proxy (intercepts all API traffic)')
parser.add_argument('--proxy-port', type=int, default=DEFAULT_PROXY_PORT, help='Proxy listen port')
parser.add_argument('--target-port', type=int, default=19999, help='Target port (the real o11 API)')
parser.add_argument('--no-proc', action='store_true', help='Disable process monitoring')
parser.add_argument('--no-files', action='store_true', help='Disable file watching')
parser.add_argument('--once', action='store_true', help='Run one scan and exit')
args = parser.parse_args()
logger = AuditLogger(args.log, args.alerts)
detector = AttackDetector(logger)
logger.log('startup', 'INFO', 'main', f'Security monitor v{VERSION} starting')
logger.log('startup', 'INFO', 'main', f'Audit log: {args.log}')
logger.log('startup', 'INFO', 'main', f'Alerts log: {args.alerts}')
if args.proxy_mode:
proxy_thread = threading.Thread(
target=run_proxy,
args=(args.proxy_port, '127.0.0.1', args.target_port, detector, logger),
daemon=True
)
proxy_thread.start()
logger.log('startup', 'INFO', 'main',
f'Proxy mode active point your client to :{args.proxy_port} instead of :{args.target_port}')
pid = args.pid or find_o11_pid()
if not pid:
logger.log('startup', 'ERROR', 'main', 'Could not find o11 process. Use --pid to specify.')
if not args.proxy_mode:
sys.exit(1)
logger.log('startup', 'INFO', 'main', 'Running in proxy-only mode (no process monitoring)')
else:
logger.log('startup', 'INFO', 'main', f'Monitoring PID {pid}')
if pid and not args.no_proc:
proc_monitor = ProcessMonitor(pid, logger, detector)
if args.once:
proc_monitor.scan()
else:
proc_thread = threading.Thread(target=proc_monitor.run, daemon=True)
proc_thread.start()
if not args.no_files:
watcher = FileWatcher(logger)
if args.once:
watcher.scan()
else:
watch_thread = threading.Thread(target=watcher.run, daemon=True)
watch_thread.start()
if args.once:
logger.log('shutdown', 'INFO', 'main', 'One-shot scan complete')
return
modes = []
if args.proxy_mode:
modes.append(f'proxy on :{args.proxy_port}')
if pid and not args.no_proc:
modes.append(f'process monitor (PID {pid})')
if not args.no_files:
modes.append('file watcher')
logger.log('startup', 'INFO', 'main',
f'Monitor running {", ".join(modes) or "no active modules"}. Press Ctrl+C to stop.')
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.log('shutdown', 'INFO', 'main', 'Shutting down (Ctrl+C)')
if __name__ == '__main__':
main()