ghostnexus-node 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.
- ghostnexus_node/__init__.py +2 -0
- ghostnexus_node/cli.py +80 -0
- ghostnexus_node/gpu_detector.py +74 -0
- ghostnexus_node/node.py +485 -0
- ghostnexus_node-0.1.0.dist-info/METADATA +72 -0
- ghostnexus_node-0.1.0.dist-info/RECORD +9 -0
- ghostnexus_node-0.1.0.dist-info/WHEEL +5 -0
- ghostnexus_node-0.1.0.dist-info/entry_points.txt +2 -0
- ghostnexus_node-0.1.0.dist-info/top_level.txt +1 -0
ghostnexus_node/cli.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GhostNexus Node CLI — ghostnexus-node start --api-key gn_live_...
|
|
3
|
+
"""
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="ghostnexus-node",
|
|
13
|
+
description="GhostNexus Provider Node — earn money with your idle GPU",
|
|
14
|
+
)
|
|
15
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
16
|
+
|
|
17
|
+
# start command
|
|
18
|
+
start_parser = subparsers.add_parser("start", help="Start the provider node")
|
|
19
|
+
start_parser.add_argument(
|
|
20
|
+
"--api-key",
|
|
21
|
+
default=os.environ.get("GHOSTNEXUS_PROVIDER_API_KEY", ""),
|
|
22
|
+
help="Your GhostNexus provider API key (gn_live_...)",
|
|
23
|
+
)
|
|
24
|
+
start_parser.add_argument(
|
|
25
|
+
"--server",
|
|
26
|
+
default="wss://ghostnexus.net/ws/provider",
|
|
27
|
+
help="GhostNexus server WebSocket URL",
|
|
28
|
+
)
|
|
29
|
+
start_parser.add_argument(
|
|
30
|
+
"--gpu-passthrough",
|
|
31
|
+
action="store_true",
|
|
32
|
+
default=True,
|
|
33
|
+
help="Pass GPU to Docker containers (default: True)",
|
|
34
|
+
)
|
|
35
|
+
start_parser.add_argument(
|
|
36
|
+
"--memory",
|
|
37
|
+
default="8g",
|
|
38
|
+
help="Memory limit per job (default: 8g)",
|
|
39
|
+
)
|
|
40
|
+
start_parser.add_argument(
|
|
41
|
+
"--cpus",
|
|
42
|
+
default="4.0",
|
|
43
|
+
help="CPU limit per job (default: 4.0)",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
args = parser.parse_args()
|
|
47
|
+
|
|
48
|
+
if args.command == "start":
|
|
49
|
+
if not args.api_key:
|
|
50
|
+
print(
|
|
51
|
+
"Error: API key required.\n"
|
|
52
|
+
" ghostnexus-node start --api-key gn_live_...\n"
|
|
53
|
+
" or set GHOSTNEXUS_PROVIDER_API_KEY environment variable\n\n"
|
|
54
|
+
"Get your provider API key at: https://ghostnexus.net/login"
|
|
55
|
+
)
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
# Set env vars and import the actual provider node
|
|
59
|
+
os.environ["GHOSTNEXUS_WS_URL"] = args.server
|
|
60
|
+
os.environ["GHOSTNEXUS_PROVIDER_API_KEY"] = args.api_key
|
|
61
|
+
os.environ["GHOSTNEXUS_GPU_PASSTHROUGH"] = "true" if args.gpu_passthrough else "false"
|
|
62
|
+
os.environ["GHOSTNEXUS_JOB_MEMORY"] = args.memory
|
|
63
|
+
os.environ["GHOSTNEXUS_JOB_CPUS"] = args.cpus
|
|
64
|
+
os.environ["GHOSTNEXUS_JOB_TIMEOUT"] = "3600"
|
|
65
|
+
|
|
66
|
+
print(f"GhostNexus Provider Node v0.1.0")
|
|
67
|
+
print(f"Connecting to {args.server} ...")
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
from ghostnexus_node.node import run
|
|
71
|
+
run()
|
|
72
|
+
except ImportError as e:
|
|
73
|
+
print(f"Error loading node: {e}")
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
else:
|
|
76
|
+
parser.print_help()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
main()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GhostNexus - Provider Node
|
|
3
|
+
GPU detection module (fake/mock for development without NVIDIA hardware).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class GPUInfo:
|
|
15
|
+
"""GPU hardware information."""
|
|
16
|
+
|
|
17
|
+
model: str
|
|
18
|
+
vram_mb: int
|
|
19
|
+
temperature_c: Optional[int] = None
|
|
20
|
+
utilization_pct: Optional[int] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def detect_gpu() -> Optional[GPUInfo]:
|
|
24
|
+
"""
|
|
25
|
+
Detect available GPU. Uses pynvml if NVIDIA GPU present, otherwise returns mock data.
|
|
26
|
+
Mock data allows development/testing without physical GPU.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
import pynvml
|
|
30
|
+
|
|
31
|
+
pynvml.nvmlInit()
|
|
32
|
+
handle = pynvml.nvmlDeviceGetHandleByIndex(0)
|
|
33
|
+
name = pynvml.nvmlDeviceGetName(handle)
|
|
34
|
+
if isinstance(name, bytes):
|
|
35
|
+
name = name.decode("utf-8", errors="replace")
|
|
36
|
+
mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
|
37
|
+
vram_mb = mem_info.total // (1024 * 1024)
|
|
38
|
+
|
|
39
|
+
temp = None
|
|
40
|
+
try:
|
|
41
|
+
temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU)
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
util = None
|
|
46
|
+
try:
|
|
47
|
+
util = pynvml.nvmlDeviceGetUtilizationRates(handle)
|
|
48
|
+
util = util.gpu if util else None
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
pynvml.nvmlShutdown()
|
|
53
|
+
return GPUInfo(
|
|
54
|
+
model=name.strip(),
|
|
55
|
+
vram_mb=vram_mb,
|
|
56
|
+
temperature_c=temp,
|
|
57
|
+
utilization_pct=util,
|
|
58
|
+
)
|
|
59
|
+
except ImportError:
|
|
60
|
+
logger.info("pynvml not installed, using mock GPU for development")
|
|
61
|
+
return _get_mock_gpu()
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logger.warning(f"GPU detection failed: {e}, using mock GPU")
|
|
64
|
+
return _get_mock_gpu()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_mock_gpu() -> GPUInfo:
|
|
68
|
+
"""Return fake GPU info for development/testing without NVIDIA hardware."""
|
|
69
|
+
return GPUInfo(
|
|
70
|
+
model="NVIDIA GeForce RTX 4090 (Mock)",
|
|
71
|
+
vram_mb=24576,
|
|
72
|
+
temperature_c=45,
|
|
73
|
+
utilization_pct=0,
|
|
74
|
+
)
|
ghostnexus_node/node.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GhostNexus - Provider Node
|
|
3
|
+
Lightweight client that detects GPU, connects via WebSocket, sends heartbeat,
|
|
4
|
+
and executes Python scripts received from the Matchmaker.
|
|
5
|
+
|
|
6
|
+
Job execution is sandboxed inside Docker containers with strict resource and
|
|
7
|
+
security limits (no network, no new privileges, read-only filesystem, CPU/RAM cap).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from ghostnexus_node.gpu_detector import GPUInfo, detect_gpu
|
|
20
|
+
|
|
21
|
+
logging.basicConfig(
|
|
22
|
+
level=os.getenv("LOG_LEVEL", "INFO").upper(),
|
|
23
|
+
format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
|
|
24
|
+
stream=sys.stdout,
|
|
25
|
+
)
|
|
26
|
+
logger = logging.getLogger("ghostnexus.provider")
|
|
27
|
+
|
|
28
|
+
# ── Configuration ─────────────────────────────────────────────────────────────
|
|
29
|
+
WS_URL = os.getenv("GHOSTNEXUS_WS_URL", "ws://localhost:8000/ws/provider")
|
|
30
|
+
PROVIDER_API_KEY = os.getenv("GHOSTNEXUS_PROVIDER_API_KEY", "")
|
|
31
|
+
HEARTBEAT_INTERVAL = 10 # seconds
|
|
32
|
+
|
|
33
|
+
# Build authenticated WebSocket URL (token passed as query param — auth happens
|
|
34
|
+
# server-side before websocket.accept(), so no token = immediate close with 4001)
|
|
35
|
+
def _build_ws_url() -> str:
|
|
36
|
+
if not PROVIDER_API_KEY:
|
|
37
|
+
logger.error(
|
|
38
|
+
"GHOSTNEXUS_PROVIDER_API_KEY is not set. "
|
|
39
|
+
"Set this environment variable to your provider API key and restart."
|
|
40
|
+
)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
separator = "&" if "?" in WS_URL else "?"
|
|
43
|
+
return f"{WS_URL}{separator}token={PROVIDER_API_KEY}"
|
|
44
|
+
|
|
45
|
+
# Reconnect backoff: starts at 3s, doubles each attempt, capped at 60s
|
|
46
|
+
_RECONNECT_MIN = 3
|
|
47
|
+
_RECONNECT_MAX = 60
|
|
48
|
+
|
|
49
|
+
# ── Docker sandbox configuration ──────────────────────────────────────────────
|
|
50
|
+
DOCKER_IMAGE = os.getenv("GHOSTNEXUS_DOCKER_IMAGE", "python:3.11-slim")
|
|
51
|
+
JOB_MEMORY_LIMIT = os.getenv("GHOSTNEXUS_JOB_MEMORY", "512m")
|
|
52
|
+
JOB_CPU_LIMIT = os.getenv("GHOSTNEXUS_JOB_CPUS", "1.0")
|
|
53
|
+
GPU_PASSTHROUGH = os.getenv("GHOSTNEXUS_GPU_PASSTHROUGH", "false").lower() == "true"
|
|
54
|
+
JOB_TIMEOUT = int(os.getenv("GHOSTNEXUS_JOB_TIMEOUT", "300"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _check_docker() -> bool:
|
|
58
|
+
"""Verify that Docker daemon is reachable before accepting any jobs."""
|
|
59
|
+
try:
|
|
60
|
+
proc = await asyncio.create_subprocess_exec(
|
|
61
|
+
"docker", "info",
|
|
62
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
63
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
64
|
+
)
|
|
65
|
+
await proc.wait()
|
|
66
|
+
return proc.returncode == 0
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _build_docker_cmd(container_name: str, script_host_path: str) -> list[str]:
|
|
72
|
+
"""
|
|
73
|
+
Construct the `docker run` command that sandboxes the user script.
|
|
74
|
+
|
|
75
|
+
Security flags applied:
|
|
76
|
+
--security-opt no-new-privileges:true prevent setuid/setgid escalation
|
|
77
|
+
--network=none no inbound or outbound network
|
|
78
|
+
--read-only immutable root filesystem
|
|
79
|
+
--tmpfs /tmp writable scratch space (no exec, size-capped)
|
|
80
|
+
--cap-drop=ALL drop all Linux capabilities
|
|
81
|
+
--memory / --cpus hard resource limits
|
|
82
|
+
--user 65534 run as nobody (unprivileged UID)
|
|
83
|
+
"""
|
|
84
|
+
cmd = [
|
|
85
|
+
"docker", "run",
|
|
86
|
+
"--rm",
|
|
87
|
+
"--name", container_name,
|
|
88
|
+
"--network=none",
|
|
89
|
+
"--memory", JOB_MEMORY_LIMIT,
|
|
90
|
+
"--memory-swap", JOB_MEMORY_LIMIT, # disables swap
|
|
91
|
+
"--cpus", JOB_CPU_LIMIT,
|
|
92
|
+
"--read-only",
|
|
93
|
+
"--tmpfs", "/tmp:size=64m,noexec,nosuid",
|
|
94
|
+
"--cap-drop=ALL",
|
|
95
|
+
"--security-opt", "no-new-privileges:true",
|
|
96
|
+
"-v", f"{script_host_path}:/job/script.py:ro",
|
|
97
|
+
"-w", "/job",
|
|
98
|
+
"--user", "65534:65534", # nobody:nogroup
|
|
99
|
+
]
|
|
100
|
+
if GPU_PASSTHROUGH:
|
|
101
|
+
cmd.extend(["--gpus", "all"])
|
|
102
|
+
cmd.extend([DOCKER_IMAGE, "python", "/job/script.py"])
|
|
103
|
+
return cmd
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def _kill_container(container_name: str) -> None:
|
|
107
|
+
"""Send SIGKILL to a running container (best-effort)."""
|
|
108
|
+
try:
|
|
109
|
+
proc = await asyncio.create_subprocess_exec(
|
|
110
|
+
"docker", "kill", container_name,
|
|
111
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
112
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
113
|
+
)
|
|
114
|
+
await asyncio.wait_for(proc.wait(), timeout=10.0)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
logger.warning(f"docker kill {container_name}: {exc}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── Heartbeat ─────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
async def heartbeat_sender(ws, gpu: GPUInfo):
|
|
122
|
+
"""Send heartbeat every 10 seconds."""
|
|
123
|
+
while True:
|
|
124
|
+
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
|
125
|
+
try:
|
|
126
|
+
await ws.send(
|
|
127
|
+
json.dumps(
|
|
128
|
+
{
|
|
129
|
+
"type": "heartbeat",
|
|
130
|
+
"vram_mb": gpu.vram_mb,
|
|
131
|
+
"temperature_c": gpu.temperature_c,
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
logger.debug("Heartbeat sent")
|
|
136
|
+
except Exception:
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Message receiver ──────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
async def message_receiver(ws, gpu: GPUInfo):
|
|
143
|
+
"""Listen for messages: heartbeat_ack or job_execute."""
|
|
144
|
+
while True:
|
|
145
|
+
try:
|
|
146
|
+
msg = await ws.recv()
|
|
147
|
+
data = json.loads(msg)
|
|
148
|
+
msg_type = data.get("type", "unknown")
|
|
149
|
+
|
|
150
|
+
if msg_type == "heartbeat_ack":
|
|
151
|
+
logger.debug("Heartbeat acknowledged")
|
|
152
|
+
|
|
153
|
+
elif msg_type == "job_execute":
|
|
154
|
+
job_id = data.get("job_id")
|
|
155
|
+
task_name = data.get("task_name", "unknown")
|
|
156
|
+
script_content = data.get("script_content", "")
|
|
157
|
+
|
|
158
|
+
if not script_content:
|
|
159
|
+
await ws.send(
|
|
160
|
+
json.dumps(
|
|
161
|
+
{
|
|
162
|
+
"type": "job_result",
|
|
163
|
+
"job_id": job_id,
|
|
164
|
+
"status": "failed",
|
|
165
|
+
"task_name": task_name,
|
|
166
|
+
"duration_seconds": 0,
|
|
167
|
+
"stdout": "",
|
|
168
|
+
"stderr": "No script content provided",
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
logger.info(
|
|
175
|
+
f"Job received: {job_id} | task={task_name} | "
|
|
176
|
+
f"spawning Docker container (image={DOCKER_IMAGE})"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Safe container name: alphanumeric + dash, max 63 chars
|
|
180
|
+
container_name = f"gn-job-{job_id[:20]}"
|
|
181
|
+
temp_path = None
|
|
182
|
+
try:
|
|
183
|
+
# Write script to a named temp file so Docker can bind-mount it
|
|
184
|
+
with tempfile.NamedTemporaryFile(
|
|
185
|
+
mode="w",
|
|
186
|
+
suffix="_job.py",
|
|
187
|
+
delete=False,
|
|
188
|
+
encoding="utf-8",
|
|
189
|
+
) as f:
|
|
190
|
+
f.write(script_content)
|
|
191
|
+
temp_path = f.name
|
|
192
|
+
|
|
193
|
+
docker_cmd = _build_docker_cmd(container_name, temp_path)
|
|
194
|
+
start = time.perf_counter()
|
|
195
|
+
|
|
196
|
+
proc = await asyncio.create_subprocess_exec(
|
|
197
|
+
*docker_cmd,
|
|
198
|
+
stdout=asyncio.subprocess.PIPE,
|
|
199
|
+
stderr=asyncio.subprocess.PIPE,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
task_comm = asyncio.create_task(
|
|
203
|
+
asyncio.wait_for(proc.communicate(), timeout=JOB_TIMEOUT)
|
|
204
|
+
)
|
|
205
|
+
task_recv = asyncio.create_task(ws.recv())
|
|
206
|
+
|
|
207
|
+
done, pending = await asyncio.wait(
|
|
208
|
+
[task_comm, task_recv],
|
|
209
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
killed = False
|
|
213
|
+
|
|
214
|
+
if task_recv in done:
|
|
215
|
+
# A message arrived while job was running
|
|
216
|
+
try:
|
|
217
|
+
recv_data = json.loads(task_recv.result())
|
|
218
|
+
if (
|
|
219
|
+
recv_data.get("type") == "job_kill"
|
|
220
|
+
and recv_data.get("job_id") == job_id
|
|
221
|
+
):
|
|
222
|
+
killed = True
|
|
223
|
+
# Kill the Docker container, not just the docker-run process
|
|
224
|
+
await _kill_container(container_name)
|
|
225
|
+
try:
|
|
226
|
+
stdout, stderr = await asyncio.wait_for(
|
|
227
|
+
proc.communicate(), timeout=10.0
|
|
228
|
+
)
|
|
229
|
+
except asyncio.TimeoutError:
|
|
230
|
+
proc.kill()
|
|
231
|
+
stdout, stderr = await proc.communicate()
|
|
232
|
+
else:
|
|
233
|
+
# Unrelated message — let job finish
|
|
234
|
+
stdout, stderr = await task_comm
|
|
235
|
+
except (json.JSONDecodeError, KeyError):
|
|
236
|
+
stdout, stderr = await task_comm
|
|
237
|
+
else:
|
|
238
|
+
# Job finished first
|
|
239
|
+
try:
|
|
240
|
+
stdout, stderr = await task_comm
|
|
241
|
+
except asyncio.TimeoutError:
|
|
242
|
+
# Hard timeout: kill container
|
|
243
|
+
await _kill_container(container_name)
|
|
244
|
+
try:
|
|
245
|
+
proc.terminate()
|
|
246
|
+
except ProcessLookupError:
|
|
247
|
+
pass
|
|
248
|
+
stdout, stderr = await proc.communicate()
|
|
249
|
+
duration_seconds = time.perf_counter() - start
|
|
250
|
+
await ws.send(
|
|
251
|
+
json.dumps(
|
|
252
|
+
{
|
|
253
|
+
"type": "job_result",
|
|
254
|
+
"job_id": job_id,
|
|
255
|
+
"status": "failed",
|
|
256
|
+
"task_name": task_name,
|
|
257
|
+
"duration_seconds": round(duration_seconds, 2),
|
|
258
|
+
"stdout": (stdout or b"").decode("utf-8", errors="replace"),
|
|
259
|
+
"stderr": f"Script execution timed out ({JOB_TIMEOUT}s)",
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
for t in pending:
|
|
264
|
+
t.cancel()
|
|
265
|
+
try:
|
|
266
|
+
await t
|
|
267
|
+
except (asyncio.CancelledError, Exception):
|
|
268
|
+
pass
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
# Cancel any remaining tasks
|
|
272
|
+
for t in pending:
|
|
273
|
+
t.cancel()
|
|
274
|
+
try:
|
|
275
|
+
await t
|
|
276
|
+
except (asyncio.CancelledError, Exception):
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
duration_seconds = time.perf_counter() - start
|
|
280
|
+
stdout_str = (stdout or b"").decode("utf-8", errors="replace")
|
|
281
|
+
stderr_str = (stderr or b"").decode("utf-8", errors="replace")
|
|
282
|
+
|
|
283
|
+
if killed:
|
|
284
|
+
status = "failed_insufficient_funds"
|
|
285
|
+
stderr_str = "Insufficient funds — job killed by backend"
|
|
286
|
+
logger.info(f"Job {job_id} killed (insufficient funds)")
|
|
287
|
+
else:
|
|
288
|
+
status = "success" if proc.returncode == 0 else "failed"
|
|
289
|
+
logger.info(
|
|
290
|
+
f"Job {job_id} completed | status={status} | "
|
|
291
|
+
f"duration={duration_seconds:.2f}s | "
|
|
292
|
+
f"exit_code={proc.returncode}"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
await ws.send(
|
|
296
|
+
json.dumps(
|
|
297
|
+
{
|
|
298
|
+
"type": "job_result",
|
|
299
|
+
"job_id": job_id,
|
|
300
|
+
"status": status,
|
|
301
|
+
"task_name": task_name,
|
|
302
|
+
"duration_seconds": round(duration_seconds, 2),
|
|
303
|
+
"stdout": stdout_str,
|
|
304
|
+
"stderr": stderr_str,
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
except asyncio.CancelledError:
|
|
310
|
+
raise
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.exception(f"Job {job_id} execution error: {e}")
|
|
313
|
+
await ws.send(
|
|
314
|
+
json.dumps(
|
|
315
|
+
{
|
|
316
|
+
"type": "job_result",
|
|
317
|
+
"job_id": job_id,
|
|
318
|
+
"status": "failed",
|
|
319
|
+
"task_name": task_name,
|
|
320
|
+
"duration_seconds": 0,
|
|
321
|
+
"stdout": "",
|
|
322
|
+
"stderr": str(e),
|
|
323
|
+
}
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
finally:
|
|
327
|
+
if temp_path and os.path.exists(temp_path):
|
|
328
|
+
try:
|
|
329
|
+
os.unlink(temp_path)
|
|
330
|
+
except OSError:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
elif msg_type == "result_received":
|
|
334
|
+
logger.debug("Backend acknowledged job result")
|
|
335
|
+
|
|
336
|
+
else:
|
|
337
|
+
logger.warning(f"Unknown message type: {msg_type}")
|
|
338
|
+
|
|
339
|
+
except Exception as e:
|
|
340
|
+
logger.error(f"Message receiver error: {e}")
|
|
341
|
+
raise
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ── Main loop ─────────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
async def run_provider_node(shutdown: asyncio.Event):
|
|
347
|
+
"""Main loop: connect, register GPU, heartbeat + job listener.
|
|
348
|
+
Reconnects with exponential backoff on disconnect.
|
|
349
|
+
"""
|
|
350
|
+
import websockets
|
|
351
|
+
|
|
352
|
+
# Verify Docker is available before anything else
|
|
353
|
+
if not await _check_docker():
|
|
354
|
+
logger.error(
|
|
355
|
+
"Docker daemon is not reachable. "
|
|
356
|
+
"Install Docker and ensure the daemon is running, then retry."
|
|
357
|
+
)
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
logger.info(
|
|
360
|
+
f"Docker OK | image={DOCKER_IMAGE} | "
|
|
361
|
+
f"mem={JOB_MEMORY_LIMIT} | cpus={JOB_CPU_LIMIT} | "
|
|
362
|
+
f"gpu_passthrough={GPU_PASSTHROUGH}"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
gpu = detect_gpu()
|
|
366
|
+
if not gpu:
|
|
367
|
+
logger.error("No GPU detected — exiting.")
|
|
368
|
+
sys.exit(1)
|
|
369
|
+
|
|
370
|
+
ws_connect_url = _build_ws_url()
|
|
371
|
+
logger.info(f"GPU detected: {gpu.model} | VRAM: {gpu.vram_mb} MB")
|
|
372
|
+
logger.info(f"Connecting to {WS_URL} …")
|
|
373
|
+
|
|
374
|
+
reconnect_delay = _RECONNECT_MIN
|
|
375
|
+
attempt = 0
|
|
376
|
+
|
|
377
|
+
while not shutdown.is_set():
|
|
378
|
+
attempt += 1
|
|
379
|
+
try:
|
|
380
|
+
async with websockets.connect(
|
|
381
|
+
ws_connect_url,
|
|
382
|
+
ping_interval=20,
|
|
383
|
+
ping_timeout=10,
|
|
384
|
+
close_timeout=5,
|
|
385
|
+
) as ws:
|
|
386
|
+
reconnect_delay = _RECONNECT_MIN # reset on successful connect
|
|
387
|
+
logger.info(f"Connected (attempt #{attempt})")
|
|
388
|
+
|
|
389
|
+
# Register with GPU info (identity already verified via ?token= at connect)
|
|
390
|
+
register_payload = {
|
|
391
|
+
"type": "register",
|
|
392
|
+
"gpu_model": gpu.model,
|
|
393
|
+
"vram_mb": gpu.vram_mb,
|
|
394
|
+
"temperature_c": gpu.temperature_c,
|
|
395
|
+
"utilization_pct": gpu.utilization_pct,
|
|
396
|
+
}
|
|
397
|
+
logger.info("Sending GPU registration")
|
|
398
|
+
|
|
399
|
+
await ws.send(json.dumps(register_payload))
|
|
400
|
+
reg_response = await ws.recv()
|
|
401
|
+
reg_data = json.loads(reg_response)
|
|
402
|
+
if reg_data.get("type") == "registered":
|
|
403
|
+
logger.info(
|
|
404
|
+
f"Registered as node {reg_data.get('node_id')} | "
|
|
405
|
+
f"GPU: {gpu.model} | Status: {reg_data.get('status', 'ok')}"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Run heartbeat + message receiver concurrently; stop on shutdown
|
|
409
|
+
async def _shutdown_watcher():
|
|
410
|
+
await shutdown.wait()
|
|
411
|
+
|
|
412
|
+
done, pending = await asyncio.wait(
|
|
413
|
+
[
|
|
414
|
+
asyncio.create_task(heartbeat_sender(ws, gpu)),
|
|
415
|
+
asyncio.create_task(message_receiver(ws, gpu)),
|
|
416
|
+
asyncio.create_task(_shutdown_watcher()),
|
|
417
|
+
],
|
|
418
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
419
|
+
)
|
|
420
|
+
for task in pending:
|
|
421
|
+
task.cancel()
|
|
422
|
+
try:
|
|
423
|
+
await task
|
|
424
|
+
except asyncio.CancelledError:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
if shutdown.is_set():
|
|
428
|
+
logger.info("Shutdown signal received — disconnecting cleanly.")
|
|
429
|
+
break
|
|
430
|
+
|
|
431
|
+
except Exception as e:
|
|
432
|
+
logger.error(
|
|
433
|
+
f"Connection lost: {e} — reconnecting in {reconnect_delay}s "
|
|
434
|
+
f"(attempt #{attempt})"
|
|
435
|
+
)
|
|
436
|
+
try:
|
|
437
|
+
await asyncio.wait_for(shutdown.wait(), timeout=reconnect_delay)
|
|
438
|
+
break # Shutdown was requested during wait
|
|
439
|
+
except asyncio.TimeoutError:
|
|
440
|
+
pass
|
|
441
|
+
reconnect_delay = min(reconnect_delay * 2, _RECONNECT_MAX)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _handle_signal(shutdown: asyncio.Event):
|
|
445
|
+
logger.info("Interrupt received — shutting down …")
|
|
446
|
+
shutdown.set()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
if __name__ == "__main__":
|
|
450
|
+
loop = asyncio.new_event_loop()
|
|
451
|
+
asyncio.set_event_loop(loop)
|
|
452
|
+
shutdown_event = asyncio.Event()
|
|
453
|
+
|
|
454
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
455
|
+
try:
|
|
456
|
+
loop.add_signal_handler(sig, _handle_signal, shutdown_event)
|
|
457
|
+
except NotImplementedError:
|
|
458
|
+
# Windows doesn't support add_signal_handler for all signals
|
|
459
|
+
pass
|
|
460
|
+
|
|
461
|
+
try:
|
|
462
|
+
loop.run_until_complete(run_provider_node(shutdown_event))
|
|
463
|
+
finally:
|
|
464
|
+
loop.close()
|
|
465
|
+
logger.info("Provider node stopped.")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def run():
|
|
469
|
+
"""Entry point called by CLI."""
|
|
470
|
+
import signal
|
|
471
|
+
loop = asyncio.new_event_loop()
|
|
472
|
+
asyncio.set_event_loop(loop)
|
|
473
|
+
shutdown_event = asyncio.Event()
|
|
474
|
+
|
|
475
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
476
|
+
try:
|
|
477
|
+
loop.add_signal_handler(sig, _handle_signal, shutdown_event)
|
|
478
|
+
except NotImplementedError:
|
|
479
|
+
pass
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
loop.run_until_complete(run_provider_node(shutdown_event))
|
|
483
|
+
finally:
|
|
484
|
+
loop.close()
|
|
485
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ghostnexus-node
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: GhostNexus Provider Node — earn money with your idle GPU
|
|
5
|
+
Author-email: GhostNexus <contact@ghostnexus.net>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://ghostnexus.net/become-provider
|
|
8
|
+
Project-URL: Documentation, https://ghostnexus.net/become-provider
|
|
9
|
+
Project-URL: Repository, https://github.com/ghostnexus/ghostnexus-node
|
|
10
|
+
Keywords: gpu,mining,ai,compute,provider,passive-income
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: websockets>=12.0
|
|
22
|
+
Requires-Dist: nvidia-ml-py>=12.0
|
|
23
|
+
|
|
24
|
+
# ghostnexus-node
|
|
25
|
+
|
|
26
|
+
**Earn money with your idle GPU — GhostNexus Provider Node.**
|
|
27
|
+
|
|
28
|
+
Turn your gaming GPU into a passive income source. When you're not gaming, your GPU runs AI training jobs for other developers and you earn money.
|
|
29
|
+
|
|
30
|
+
- **RTX 4070**: up to ~$90/month at 50% utilization
|
|
31
|
+
- **RTX 4090**: up to ~$126/month at 50% utilization
|
|
32
|
+
- **70% revenue share** — paid via Wise or crypto from $50
|
|
33
|
+
- Fully isolated Docker sandbox — your data is never exposed
|
|
34
|
+
- Stop anytime — no contract, no commitment
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install ghostnexus-node
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Docker required: [docker.com/products/docker-desktop](https://www.docker.com/products/docker-desktop/)
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Get your provider API key at ghostnexus.net/login (free)
|
|
48
|
+
ghostnexus-node start --api-key gn_live_...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or set it as an environment variable:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
export GHOSTNEXUS_PROVIDER_API_KEY="gn_live_..."
|
|
55
|
+
ghostnexus-node start
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## How It Works
|
|
59
|
+
|
|
60
|
+
1. Your node connects to the GhostNexus network
|
|
61
|
+
2. When a client submits a job, it's dispatched to your GPU
|
|
62
|
+
3. The job runs in an isolated Docker container (no network access, memory/CPU limits)
|
|
63
|
+
4. Results are sent back, you earn credits
|
|
64
|
+
5. Cash out via Wise or crypto once you reach $50
|
|
65
|
+
|
|
66
|
+
## Sign Up
|
|
67
|
+
|
|
68
|
+
Create your provider account at [ghostnexus.net/login](https://ghostnexus.net/login) — it's free.
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ghostnexus_node/__init__.py,sha256=SLGxt8TYYAK37iOT2qDNm21RA0gbqovjhEI3vqMEutY,88
|
|
2
|
+
ghostnexus_node/cli.py,sha256=FwOKGUPHsUVT4XEt_VVZUUvcvvJh-scD6HQGioWzqjw,2461
|
|
3
|
+
ghostnexus_node/gpu_detector.py,sha256=Kpm7hCHCmLWXw0QZG95xATIW2ojx1Bblf1yX3WiVquQ,2116
|
|
4
|
+
ghostnexus_node/node.py,sha256=4sy5aM4J_qLXM7OPXWCxnWhqkYtpToTpG4VUIwqz2NA,19296
|
|
5
|
+
ghostnexus_node-0.1.0.dist-info/METADATA,sha256=1JC9eOeZ3kFpo5MahMEBRhqeN1JLzKi4hj3TnbQ3SzI,2399
|
|
6
|
+
ghostnexus_node-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
ghostnexus_node-0.1.0.dist-info/entry_points.txt,sha256=TGajxybnqT9Z1kOimjA5oQ2v2tuTt_QCtDfwagix5fk,61
|
|
8
|
+
ghostnexus_node-0.1.0.dist-info/top_level.txt,sha256=ZMrESYDaNalbqfnhK2kZMdgSMO-qly7usIRkLfrVm6Y,16
|
|
9
|
+
ghostnexus_node-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ghostnexus_node
|