h4-debug 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- h4_debug/__init__.py +1 -0
- h4_debug/cli.py +103 -0
- h4_debug/interceptor.py +197 -0
- h4_debug/server.py +94 -0
- h4_debug-0.1.0.dist-info/METADATA +21 -0
- h4_debug-0.1.0.dist-info/RECORD +9 -0
- h4_debug-0.1.0.dist-info/WHEEL +5 -0
- h4_debug-0.1.0.dist-info/entry_points.txt +2 -0
- h4_debug-0.1.0.dist-info/top_level.txt +1 -0
h4_debug/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
h4_debug/cli.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import webbrowser
|
|
9
|
+
import pathlib
|
|
10
|
+
|
|
11
|
+
from . import server
|
|
12
|
+
|
|
13
|
+
def create_sitecustomize(mode):
|
|
14
|
+
temp_dir = tempfile.mkdtemp(prefix="h4_debug_")
|
|
15
|
+
sitecustomize_path = os.path.join(temp_dir, "sitecustomize.py")
|
|
16
|
+
|
|
17
|
+
# We will write a sitecustomize.py that imports our interceptor
|
|
18
|
+
# and initializes it with the selected mode.
|
|
19
|
+
script = f"""
|
|
20
|
+
import sys
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
# Remove this temp dir from sys.path so we don't mess with other imports too much
|
|
24
|
+
try:
|
|
25
|
+
sys.path.remove(r"{temp_dir}")
|
|
26
|
+
except ValueError:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import h4_debug.interceptor
|
|
31
|
+
h4_debug.interceptor.start_interception(mode="{mode}")
|
|
32
|
+
except ImportError as e:
|
|
33
|
+
print(f"h4-debug: Failed to load interceptor: {{e}}", file=sys.stderr)
|
|
34
|
+
"""
|
|
35
|
+
with open(sitecustomize_path, "w", encoding="utf-8") as f:
|
|
36
|
+
f.write(script)
|
|
37
|
+
|
|
38
|
+
return temp_dir
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
parser = argparse.ArgumentParser(description="h4-debug: Advanced application proxy debugger", usage="h4-debug [--mode MODE] command ...")
|
|
42
|
+
parser.add_argument("--mode", choices=["Normal", "Trace", "Full"], default="Normal", help="Debugging mode")
|
|
43
|
+
parser.add_argument("--port", type=int, default=8008, help="Port for the web dashboard")
|
|
44
|
+
parser.add_argument("command", nargs=argparse.REMAINDER, help="Command to run")
|
|
45
|
+
|
|
46
|
+
args = parser.parse_args()
|
|
47
|
+
|
|
48
|
+
if not args.command:
|
|
49
|
+
parser.print_help()
|
|
50
|
+
sys.exit(1)
|
|
51
|
+
|
|
52
|
+
command = args.command
|
|
53
|
+
# argparse puts '--' in the remainder if it's there
|
|
54
|
+
if command[0] == "--":
|
|
55
|
+
command = command[1:]
|
|
56
|
+
|
|
57
|
+
# 1. Start the web server in a background thread
|
|
58
|
+
print(f"[h4-debug] Starting dashboard on http://localhost:{args.port} ...")
|
|
59
|
+
server_thread = threading.Thread(target=server.run_server, args=(args.port,), daemon=True)
|
|
60
|
+
server_thread.start()
|
|
61
|
+
|
|
62
|
+
# Give server a moment to start
|
|
63
|
+
time.sleep(1.0)
|
|
64
|
+
|
|
65
|
+
# Open dashboard in browser
|
|
66
|
+
webbrowser.open(f"http://localhost:{args.port}")
|
|
67
|
+
|
|
68
|
+
# 2. Setup interception environment
|
|
69
|
+
env = os.environ.copy()
|
|
70
|
+
|
|
71
|
+
# Python interception via sitecustomize
|
|
72
|
+
sitecustomize_dir = create_sitecustomize(args.mode)
|
|
73
|
+
|
|
74
|
+
if "PYTHONPATH" in env:
|
|
75
|
+
env["PYTHONPATH"] = f"{sitecustomize_dir}{os.pathsep}{env['PYTHONPATH']}"
|
|
76
|
+
else:
|
|
77
|
+
env["PYTHONPATH"] = sitecustomize_dir
|
|
78
|
+
|
|
79
|
+
env["H4_DEBUG_MODE"] = args.mode
|
|
80
|
+
env["H4_DEBUG_PORT"] = str(args.port)
|
|
81
|
+
|
|
82
|
+
# 3. Launch the target process
|
|
83
|
+
print(f"[h4-debug] Launching process: {' '.join(command)}")
|
|
84
|
+
try:
|
|
85
|
+
process = subprocess.Popen(command, env=env)
|
|
86
|
+
process.wait()
|
|
87
|
+
except KeyboardInterrupt:
|
|
88
|
+
print("\n[h4-debug] Interrupted by user. Terminating process...")
|
|
89
|
+
if process:
|
|
90
|
+
process.terminate()
|
|
91
|
+
process.wait()
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"[h4-debug] Error running command: {e}")
|
|
94
|
+
finally:
|
|
95
|
+
print("[h4-debug] Process finished. Dashboard will remain open until you exit (Ctrl+C).")
|
|
96
|
+
try:
|
|
97
|
+
while True:
|
|
98
|
+
time.sleep(1)
|
|
99
|
+
except KeyboardInterrupt:
|
|
100
|
+
print("[h4-debug] Shutting down.")
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
main()
|
h4_debug/interceptor.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import queue
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from websockets.sync.client import connect
|
|
14
|
+
except ImportError:
|
|
15
|
+
connect = None
|
|
16
|
+
|
|
17
|
+
# We must keep references to original functions to avoid infinite recursion
|
|
18
|
+
_original_open = builtins.open
|
|
19
|
+
_original_socket = socket.socket
|
|
20
|
+
_original_print = builtins.print
|
|
21
|
+
|
|
22
|
+
class TelemetryClient:
|
|
23
|
+
def __init__(self, port):
|
|
24
|
+
self.port = port
|
|
25
|
+
self.queue = queue.Queue()
|
|
26
|
+
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
27
|
+
self.thread.start()
|
|
28
|
+
|
|
29
|
+
def _run(self):
|
|
30
|
+
ws_url = f"ws://127.0.0.1:{self.port}/ws/telemetry"
|
|
31
|
+
if not connect:
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
while True:
|
|
35
|
+
try:
|
|
36
|
+
# Use original socket context if websockets internally creates sockets
|
|
37
|
+
# This might be tricky because websockets uses asyncio or sync wrappers
|
|
38
|
+
# which use the monkeypatched socket. We must be very careful.
|
|
39
|
+
# A safer approach for the telemetry thread is to unpatch socket temporarily or
|
|
40
|
+
# ensure our hooks bypass if thread == telemetry_thread
|
|
41
|
+
with connect(ws_url) as websocket:
|
|
42
|
+
while True:
|
|
43
|
+
msg = self.queue.get()
|
|
44
|
+
websocket.send(json.dumps(msg))
|
|
45
|
+
self.queue.task_done()
|
|
46
|
+
except Exception as e:
|
|
47
|
+
time.sleep(1)
|
|
48
|
+
|
|
49
|
+
def send(self, module, event_type, data):
|
|
50
|
+
msg = {
|
|
51
|
+
"timestamp": datetime.now().isoformat(),
|
|
52
|
+
"module": module,
|
|
53
|
+
"type": event_type,
|
|
54
|
+
"data": data,
|
|
55
|
+
"thread": threading.current_thread().name
|
|
56
|
+
}
|
|
57
|
+
self.queue.put(msg)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_telemetry = None
|
|
61
|
+
_mode = "Normal"
|
|
62
|
+
_telemetry_thread_id = None
|
|
63
|
+
|
|
64
|
+
def _is_telemetry_thread():
|
|
65
|
+
return threading.get_ident() == _telemetry_thread_id
|
|
66
|
+
|
|
67
|
+
# --- Disk Interception ---
|
|
68
|
+
|
|
69
|
+
def patched_open(file, mode='r', buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None):
|
|
70
|
+
if not _is_telemetry_thread() and _telemetry:
|
|
71
|
+
_telemetry.send("Disk", "open", {
|
|
72
|
+
"file": str(file),
|
|
73
|
+
"mode": mode
|
|
74
|
+
})
|
|
75
|
+
return _original_open(file, mode, buffering, encoding, errors, newline, closefd, opener)
|
|
76
|
+
|
|
77
|
+
# --- Network Interception ---
|
|
78
|
+
|
|
79
|
+
class PatchedSocket(_original_socket):
|
|
80
|
+
def __init__(self, family=socket.AF_INET, type=socket.SOCK_STREAM, proto=0, fileno=None):
|
|
81
|
+
super().__init__(family, type, proto, fileno)
|
|
82
|
+
self._h4_peer = None
|
|
83
|
+
|
|
84
|
+
def connect(self, address):
|
|
85
|
+
if not _is_telemetry_thread() and _telemetry:
|
|
86
|
+
self._h4_peer = address
|
|
87
|
+
_telemetry.send("Network", "connect", {
|
|
88
|
+
"address": str(address)
|
|
89
|
+
})
|
|
90
|
+
return super().connect(address)
|
|
91
|
+
|
|
92
|
+
def send(self, data, flags=0):
|
|
93
|
+
if not _is_telemetry_thread() and _telemetry:
|
|
94
|
+
payload = data.hex() if _mode == "Full" else (data[:100].hex() + "..." if len(data)>100 else data.hex())
|
|
95
|
+
# try to decode as text
|
|
96
|
+
try:
|
|
97
|
+
text_payload = data.decode('utf-8', errors='ignore')
|
|
98
|
+
except Exception:
|
|
99
|
+
text_payload = None
|
|
100
|
+
|
|
101
|
+
_telemetry.send("Network", "send", {
|
|
102
|
+
"address": str(self._h4_peer),
|
|
103
|
+
"bytes": len(data),
|
|
104
|
+
"payload_hex": payload,
|
|
105
|
+
"payload_text": text_payload
|
|
106
|
+
})
|
|
107
|
+
return super().send(data, flags)
|
|
108
|
+
|
|
109
|
+
def recv(self, bufsize, flags=0):
|
|
110
|
+
data = super().recv(bufsize, flags)
|
|
111
|
+
if not _is_telemetry_thread() and _telemetry:
|
|
112
|
+
payload = data.hex() if _mode == "Full" else (data[:100].hex() + "..." if len(data)>100 else data.hex())
|
|
113
|
+
try:
|
|
114
|
+
text_payload = data.decode('utf-8', errors='ignore')
|
|
115
|
+
except Exception:
|
|
116
|
+
text_payload = None
|
|
117
|
+
|
|
118
|
+
_telemetry.send("Network", "recv", {
|
|
119
|
+
"address": str(self._h4_peer),
|
|
120
|
+
"bytes": len(data),
|
|
121
|
+
"payload_hex": payload,
|
|
122
|
+
"payload_text": text_payload
|
|
123
|
+
})
|
|
124
|
+
return data
|
|
125
|
+
|
|
126
|
+
# --- Stdout/Stderr Interception ---
|
|
127
|
+
|
|
128
|
+
class PatchedStream:
|
|
129
|
+
def __init__(self, original_stream, stream_name):
|
|
130
|
+
self.original_stream = original_stream
|
|
131
|
+
self.stream_name = stream_name
|
|
132
|
+
|
|
133
|
+
def write(self, text):
|
|
134
|
+
if not _is_telemetry_thread() and _telemetry and text.strip():
|
|
135
|
+
_telemetry.send("Console", self.stream_name, {"text": text})
|
|
136
|
+
return self.original_stream.write(text)
|
|
137
|
+
|
|
138
|
+
def flush(self):
|
|
139
|
+
return self.original_stream.flush()
|
|
140
|
+
|
|
141
|
+
def __getattr__(self, name):
|
|
142
|
+
return getattr(self.original_stream, name)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- Execution Tracing ---
|
|
146
|
+
|
|
147
|
+
def trace_calls(frame, event, arg):
|
|
148
|
+
if _is_telemetry_thread() or not _telemetry:
|
|
149
|
+
return trace_calls
|
|
150
|
+
|
|
151
|
+
if event == "call":
|
|
152
|
+
func_name = frame.f_code.co_name
|
|
153
|
+
filename = frame.f_code.co_filename
|
|
154
|
+
# Ignore our own files and standard library to avoid noise, unless Full Debug
|
|
155
|
+
if "h4_debug" not in filename and ("site-packages" not in filename or _mode == "Full"):
|
|
156
|
+
_telemetry.send("Execution", "call", {
|
|
157
|
+
"function": func_name,
|
|
158
|
+
"file": filename,
|
|
159
|
+
"line": frame.f_lineno
|
|
160
|
+
})
|
|
161
|
+
elif event == "exception" and _mode in ("Trace", "Full"):
|
|
162
|
+
exc_type, exc_value, exc_traceback = arg
|
|
163
|
+
filename = frame.f_code.co_filename
|
|
164
|
+
if "h4_debug" not in filename:
|
|
165
|
+
_telemetry.send("Execution", "exception", {
|
|
166
|
+
"type": exc_type.__name__,
|
|
167
|
+
"value": str(exc_value),
|
|
168
|
+
"file": filename,
|
|
169
|
+
"line": frame.f_lineno
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
return trace_calls
|
|
173
|
+
|
|
174
|
+
def start_interception(mode="Normal"):
|
|
175
|
+
global _telemetry, _mode, _telemetry_thread_id
|
|
176
|
+
_mode = mode
|
|
177
|
+
|
|
178
|
+
port = int(os.environ.get("H4_DEBUG_PORT", 8008))
|
|
179
|
+
_telemetry = TelemetryClient(port)
|
|
180
|
+
_telemetry_thread_id = _telemetry.thread.ident
|
|
181
|
+
|
|
182
|
+
# Patch Disk
|
|
183
|
+
builtins.open = patched_open
|
|
184
|
+
|
|
185
|
+
# Patch Network
|
|
186
|
+
socket.socket = PatchedSocket
|
|
187
|
+
|
|
188
|
+
# Patch Console
|
|
189
|
+
sys.stdout = PatchedStream(sys.stdout, "stdout")
|
|
190
|
+
sys.stderr = PatchedStream(sys.stderr, "stderr")
|
|
191
|
+
|
|
192
|
+
# Enable Tracing if required
|
|
193
|
+
if mode in ("Trace", "Full"):
|
|
194
|
+
sys.settrace(trace_calls)
|
|
195
|
+
|
|
196
|
+
_telemetry.send("System", "init", {"mode": mode, "pid": os.getpid()})
|
|
197
|
+
|
h4_debug/server.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import uvicorn
|
|
5
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
6
|
+
from fastapi.responses import HTMLResponse
|
|
7
|
+
from fastapi.staticfiles import StaticFiles
|
|
8
|
+
|
|
9
|
+
app = FastAPI()
|
|
10
|
+
|
|
11
|
+
# Store connected dashboard clients
|
|
12
|
+
dashboard_clients = []
|
|
13
|
+
|
|
14
|
+
# To persist logs for when a dashboard connects slightly after startup
|
|
15
|
+
log_history = []
|
|
16
|
+
MAX_HISTORY = 5000
|
|
17
|
+
|
|
18
|
+
# Get the path to static files
|
|
19
|
+
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
|
20
|
+
if not os.path.exists(STATIC_DIR):
|
|
21
|
+
os.makedirs(STATIC_DIR)
|
|
22
|
+
|
|
23
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
24
|
+
|
|
25
|
+
@app.get("/")
|
|
26
|
+
async def get_dashboard():
|
|
27
|
+
dashboard_path = os.path.join(STATIC_DIR, "dashboard.html")
|
|
28
|
+
if os.path.exists(dashboard_path):
|
|
29
|
+
with open(dashboard_path, "r", encoding="utf-8") as f:
|
|
30
|
+
return HTMLResponse(f.read())
|
|
31
|
+
return HTMLResponse("<h1>Dashboard not found</h1>")
|
|
32
|
+
|
|
33
|
+
@app.websocket("/ws/telemetry")
|
|
34
|
+
async def websocket_telemetry(websocket: WebSocket):
|
|
35
|
+
"""Endpoint for the intercepted process to push logs."""
|
|
36
|
+
await websocket.accept()
|
|
37
|
+
try:
|
|
38
|
+
while True:
|
|
39
|
+
data = await websocket.receive_text()
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
log_item = json.loads(data)
|
|
43
|
+
log_history.append(log_item)
|
|
44
|
+
if len(log_history) > MAX_HISTORY:
|
|
45
|
+
log_history.pop(0)
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
# Broadcast to all dashboards
|
|
50
|
+
disconnected_clients = []
|
|
51
|
+
for client in dashboard_clients:
|
|
52
|
+
try:
|
|
53
|
+
await client.send_text(data)
|
|
54
|
+
except Exception:
|
|
55
|
+
disconnected_clients.append(client)
|
|
56
|
+
|
|
57
|
+
for client in disconnected_clients:
|
|
58
|
+
if client in dashboard_clients:
|
|
59
|
+
dashboard_clients.remove(client)
|
|
60
|
+
except WebSocketDisconnect:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
@app.websocket("/ws/dashboard")
|
|
64
|
+
async def websocket_dashboard(websocket: WebSocket):
|
|
65
|
+
"""Endpoint for the web dashboard to receive logs."""
|
|
66
|
+
await websocket.accept()
|
|
67
|
+
|
|
68
|
+
# Send history on connect
|
|
69
|
+
for log_item in log_history:
|
|
70
|
+
try:
|
|
71
|
+
await websocket.send_text(json.dumps(log_item))
|
|
72
|
+
except Exception:
|
|
73
|
+
break
|
|
74
|
+
|
|
75
|
+
dashboard_clients.append(websocket)
|
|
76
|
+
try:
|
|
77
|
+
while True:
|
|
78
|
+
# Keep alive and receive commands from dashboard if needed
|
|
79
|
+
data = await websocket.receive_text()
|
|
80
|
+
cmd = json.loads(data)
|
|
81
|
+
if cmd.get("action") == "clear":
|
|
82
|
+
log_history.clear()
|
|
83
|
+
except WebSocketDisconnect:
|
|
84
|
+
if websocket in dashboard_clients:
|
|
85
|
+
dashboard_clients.remove(websocket)
|
|
86
|
+
|
|
87
|
+
def run_server(port: int):
|
|
88
|
+
# Disable uvicorn access logs to keep terminal clean
|
|
89
|
+
import logging
|
|
90
|
+
log = logging.getLogger("uvicorn.access")
|
|
91
|
+
log.setLevel(logging.WARNING)
|
|
92
|
+
|
|
93
|
+
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
|
|
94
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: h4-debug
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Advanced application proxy debugger and F12-style web console
|
|
5
|
+
Author-email: h4 <h4@example.com>
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: fastapi>=0.100
|
|
9
|
+
Requires-Dist: uvicorn[standard]>=0.20
|
|
10
|
+
Requires-Dist: websockets>=11.0
|
|
11
|
+
|
|
12
|
+
# h4-debug
|
|
13
|
+
|
|
14
|
+
Advanced application proxy debugger and F12-style web console.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install h4-debug
|
|
20
|
+
h4-debug python main.py
|
|
21
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
h4_debug/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
h4_debug/cli.py,sha256=DoGUYNFxmesQFDqtVBW1XhdK35ks55-JSK4SuUAdePU,3208
|
|
3
|
+
h4_debug/interceptor.py,sha256=ZFWhzmjKSLGJbHVZxTjthnR22jqtGWatyULCk49O7OI,6609
|
|
4
|
+
h4_debug/server.py,sha256=YVxF8C3QoVj4riddJ-qGRr8ReqdESwY8SykcRuh4T8E,3005
|
|
5
|
+
h4_debug-0.1.0.dist-info/METADATA,sha256=gk3rGtBV4gHHzGwxfk5AlzA0IakJIMwOS6evYF8mdPY,486
|
|
6
|
+
h4_debug-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
7
|
+
h4_debug-0.1.0.dist-info/entry_points.txt,sha256=xZWjVvTnVZdESrvUUl0BEwVHT3q8-5GhA58oXvK-d2g,47
|
|
8
|
+
h4_debug-0.1.0.dist-info/top_level.txt,sha256=2fWNW-Vus5YAjroqCTsKMS2I4RT9lfBkSE3aKzlWE70,9
|
|
9
|
+
h4_debug-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
h4_debug
|