haze-agent 0.1.0__tar.gz
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.
- haze_agent-0.1.0/.gitignore +44 -0
- haze_agent-0.1.0/PKG-INFO +69 -0
- haze_agent-0.1.0/README.md +38 -0
- haze_agent-0.1.0/pyproject.toml +152 -0
- haze_agent-0.1.0/src/haze/__init__.py +8 -0
- haze_agent-0.1.0/src/haze/__main__.py +12 -0
- haze_agent-0.1.0/src/haze/api/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/api/app.py +206 -0
- haze_agent-0.1.0/src/haze/api/routes_jobs.py +228 -0
- haze_agent-0.1.0/src/haze/api/routes_pairing.py +210 -0
- haze_agent-0.1.0/src/haze/api/security.py +128 -0
- haze_agent-0.1.0/src/haze/api/ws.py +142 -0
- haze_agent-0.1.0/src/haze/apiclient.py +85 -0
- haze_agent-0.1.0/src/haze/blobs/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/blobs/transfer.py +135 -0
- haze_agent-0.1.0/src/haze/cli.py +166 -0
- haze_agent-0.1.0/src/haze/cli_devnet.py +117 -0
- haze_agent-0.1.0/src/haze/cli_jobs.py +328 -0
- haze_agent-0.1.0/src/haze/cli_pairing.py +241 -0
- haze_agent-0.1.0/src/haze/config.py +243 -0
- haze_agent-0.1.0/src/haze/db/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/db/models.py +76 -0
- haze_agent-0.1.0/src/haze/db/peers.py +106 -0
- haze_agent-0.1.0/src/haze/db/session.py +76 -0
- haze_agent-0.1.0/src/haze/devnet/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/devnet/profiles/builder.toml +18 -0
- haze_agent-0.1.0/src/haze/devnet/profiles/laptop.toml +20 -0
- haze_agent-0.1.0/src/haze/devnet/profiles/nas.toml +10 -0
- haze_agent-0.1.0/src/haze/devnet/profiles/workstation.toml +26 -0
- haze_agent-0.1.0/src/haze/devnet/supervisor.py +187 -0
- haze_agent-0.1.0/src/haze/discovery/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/discovery/broadcast.py +151 -0
- haze_agent-0.1.0/src/haze/discovery/mdns.py +170 -0
- haze_agent-0.1.0/src/haze/discovery/registry.py +223 -0
- haze_agent-0.1.0/src/haze/discovery/types.py +67 -0
- haze_agent-0.1.0/src/haze/identity/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/identity/certs.py +179 -0
- haze_agent-0.1.0/src/haze/identity/keys.py +150 -0
- haze_agent-0.1.0/src/haze/identity/nodeid.py +140 -0
- haze_agent-0.1.0/src/haze/jobs/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/jobs/executor.py +500 -0
- haze_agent-0.1.0/src/haze/jobs/limits.py +192 -0
- haze_agent-0.1.0/src/haze/jobs/progress.py +252 -0
- haze_agent-0.1.0/src/haze/jobs/runtimes/__init__.py +29 -0
- haze_agent-0.1.0/src/haze/jobs/runtimes/base.py +89 -0
- haze_agent-0.1.0/src/haze/jobs/runtimes/blender.py +82 -0
- haze_agent-0.1.0/src/haze/jobs/runtimes/ffmpeg.py +104 -0
- haze_agent-0.1.0/src/haze/jobs/runtimes/hashbench.py +103 -0
- haze_agent-0.1.0/src/haze/jobs/spec.py +173 -0
- haze_agent-0.1.0/src/haze/log.py +43 -0
- haze_agent-0.1.0/src/haze/pairing/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/pairing/manager.py +236 -0
- haze_agent-0.1.0/src/haze/pairing/sas.py +127 -0
- haze_agent-0.1.0/src/haze/probe/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/probe/base.py +89 -0
- haze_agent-0.1.0/src/haze/probe/encoders.py +70 -0
- haze_agent-0.1.0/src/haze/probe/gpu.py +153 -0
- haze_agent-0.1.0/src/haze/probe/host.py +57 -0
- haze_agent-0.1.0/src/haze/probe/synthetic.py +171 -0
- haze_agent-0.1.0/src/haze/runtime.py +153 -0
- haze_agent-0.1.0/src/haze/scheduler/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/scheduler/cluster.py +121 -0
- haze_agent-0.1.0/src/haze/scheduler/decide.py +281 -0
- haze_agent-0.1.0/src/haze/scheduler/model.py +143 -0
- haze_agent-0.1.0/src/haze/transport/__init__.py +1 -0
- haze_agent-0.1.0/src/haze/transport/client.py +349 -0
- haze_agent-0.1.0/src/haze/transport/frames.py +101 -0
- haze_agent-0.1.0/src/haze/transport/handshake.py +238 -0
- haze_agent-0.1.0/src/haze/transport/server.py +385 -0
- haze_agent-0.1.0/src/haze/transport/tls.py +161 -0
- haze_agent-0.1.0/src/haze/webui/assets/index-CtGvuqj-.css +1 -0
- haze_agent-0.1.0/src/haze/webui/assets/index-DMuCrmq-.js +226 -0
- haze_agent-0.1.0/src/haze/webui/assets/index-DMuCrmq-.js.map +1 -0
- haze_agent-0.1.0/src/haze/webui/index.html +20 -0
- haze_agent-0.1.0/tests/__init__.py +0 -0
- haze_agent-0.1.0/tests/conformance/scheduler_cases.json +1386 -0
- haze_agent-0.1.0/tests/conftest.py +45 -0
- haze_agent-0.1.0/tests/test_api_security.py +133 -0
- haze_agent-0.1.0/tests/test_discovery.py +109 -0
- haze_agent-0.1.0/tests/test_identity.py +156 -0
- haze_agent-0.1.0/tests/test_jobs.py +300 -0
- haze_agent-0.1.0/tests/test_pairing_integration.py +369 -0
- haze_agent-0.1.0/tests/test_probe.py +117 -0
- haze_agent-0.1.0/tests/test_resilience.py +189 -0
- haze_agent-0.1.0/tests/test_scheduler.py +232 -0
- haze_agent-0.1.0/tests/test_spa_serving.py +61 -0
- haze_agent-0.1.0/tests/test_transfer.py +139 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# --- Python -----------------------------------------------------------------
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
venv/
|
|
6
|
+
*.egg-info/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.mypy_cache/
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
|
|
15
|
+
# --- Node -------------------------------------------------------------------
|
|
16
|
+
node_modules/
|
|
17
|
+
web/dist/
|
|
18
|
+
*.tsbuildinfo
|
|
19
|
+
|
|
20
|
+
# --- The bundled dashboard --------------------------------------------------
|
|
21
|
+
# `web/` is built into the Python wheel at package time (see agent/pyproject.toml
|
|
22
|
+
# [tool.hatch.build.force-include]). The copy that lands here is a build
|
|
23
|
+
# artifact, never a source file -- committing it would mean two sources of truth
|
|
24
|
+
# for the dashboard and a guaranteed drift bug.
|
|
25
|
+
agent/src/haze/webui/
|
|
26
|
+
|
|
27
|
+
# --- Local agent state ------------------------------------------------------
|
|
28
|
+
# Contains the Ed25519 identity seed and the dashboard bearer token. Never
|
|
29
|
+
# commit; see SECURITY.md.
|
|
30
|
+
.haze/
|
|
31
|
+
*.db
|
|
32
|
+
*.db-journal
|
|
33
|
+
|
|
34
|
+
# --- Secrets ----------------------------------------------------------------
|
|
35
|
+
.env
|
|
36
|
+
.env.*
|
|
37
|
+
!.env.example
|
|
38
|
+
!.env.production
|
|
39
|
+
|
|
40
|
+
# --- macOS ------------------------------------------------------------------
|
|
41
|
+
.DS_Store
|
|
42
|
+
|
|
43
|
+
# Firebase CLI deploy cache — local only, machine-specific hashes.
|
|
44
|
+
.firebase/
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: haze-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Haze -- pool your own machines into a private compute network
|
|
5
|
+
Project-URL: Homepage, https://github.com/CodeMaster747/Haze
|
|
6
|
+
Project-URL: Repository, https://github.com/CodeMaster747/Haze
|
|
7
|
+
Author: Rahul Akshath
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: distributed-computing,lan,resource-sharing,self-hosted
|
|
10
|
+
Requires-Python: <3.14,>=3.12
|
|
11
|
+
Requires-Dist: aiosqlite>=0.20.0
|
|
12
|
+
Requires-Dist: cryptography>=50.0.1
|
|
13
|
+
Requires-Dist: fastapi>=0.115.0
|
|
14
|
+
Requires-Dist: psutil>=6.1.0
|
|
15
|
+
Requires-Dist: pydantic-settings>=2.6.0
|
|
16
|
+
Requires-Dist: pydantic>=2.9.0
|
|
17
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.36
|
|
18
|
+
Requires-Dist: typer>=0.15.0
|
|
19
|
+
Requires-Dist: uvicorn[standard]>=0.32.0
|
|
20
|
+
Requires-Dist: zeroconf>=0.150.3
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: httpx2>=2.12.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: mypy>=1.13.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: types-psutil>=6.1.0; extra == 'dev'
|
|
28
|
+
Provides-Extra: nvidia
|
|
29
|
+
Requires-Dist: nvidia-ml-py>=12.560.30; extra == 'nvidia'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Haze — Node Agent
|
|
33
|
+
|
|
34
|
+
Pool your own machines into a private compute network. A weak laptop borrows a
|
|
35
|
+
desktop's GPU; a NAS lends its disks. You own every node, so there is no cloud
|
|
36
|
+
bill and no third party in the data path.
|
|
37
|
+
|
|
38
|
+
This package is the **Node Agent**: the process that runs on each machine. It
|
|
39
|
+
serves the Haze dashboard on `http://127.0.0.1:7433`, discovers and pairs with
|
|
40
|
+
your other machines over the LAN, reports live resources, and executes jobs
|
|
41
|
+
within limits you set.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv tool install haze-agent # or: pipx install haze-agent
|
|
47
|
+
haze up
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The distribution is `haze-agent`; the command it installs is `haze`.
|
|
51
|
+
|
|
52
|
+
## Try it on one machine
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
haze devnet up -n 4 --open # four agents with simulated hardware profiles
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Why the dashboard is served locally
|
|
59
|
+
|
|
60
|
+
A public HTTPS page can no longer reach a local agent: Safari hard-blocks it as
|
|
61
|
+
mixed content, and Chrome 142+ gates it behind a Local Network Access prompt
|
|
62
|
+
that Chrome 147 extended to WebSockets. Serving the UI from the agent's own
|
|
63
|
+
origin sidesteps all of it — the same choice Syncthing, Jellyfin, Home Assistant
|
|
64
|
+
and Ollama make.
|
|
65
|
+
|
|
66
|
+
Full documentation, architecture notes and the live demo:
|
|
67
|
+
**https://github.com/CodeMaster747/Haze**
|
|
68
|
+
|
|
69
|
+
MIT licensed.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Haze — Node Agent
|
|
2
|
+
|
|
3
|
+
Pool your own machines into a private compute network. A weak laptop borrows a
|
|
4
|
+
desktop's GPU; a NAS lends its disks. You own every node, so there is no cloud
|
|
5
|
+
bill and no third party in the data path.
|
|
6
|
+
|
|
7
|
+
This package is the **Node Agent**: the process that runs on each machine. It
|
|
8
|
+
serves the Haze dashboard on `http://127.0.0.1:7433`, discovers and pairs with
|
|
9
|
+
your other machines over the LAN, reports live resources, and executes jobs
|
|
10
|
+
within limits you set.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
uv tool install haze-agent # or: pipx install haze-agent
|
|
16
|
+
haze up
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The distribution is `haze-agent`; the command it installs is `haze`.
|
|
20
|
+
|
|
21
|
+
## Try it on one machine
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
haze devnet up -n 4 --open # four agents with simulated hardware profiles
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Why the dashboard is served locally
|
|
28
|
+
|
|
29
|
+
A public HTTPS page can no longer reach a local agent: Safari hard-blocks it as
|
|
30
|
+
mixed content, and Chrome 142+ gates it behind a Local Network Access prompt
|
|
31
|
+
that Chrome 147 extended to WebSockets. Serving the UI from the agent's own
|
|
32
|
+
origin sidesteps all of it — the same choice Syncthing, Jellyfin, Home Assistant
|
|
33
|
+
and Ollama make.
|
|
34
|
+
|
|
35
|
+
Full documentation, architecture notes and the live demo:
|
|
36
|
+
**https://github.com/CodeMaster747/Haze**
|
|
37
|
+
|
|
38
|
+
MIT licensed.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Haze Node Agent -- the process that runs on every machine in the network.
|
|
2
|
+
#
|
|
3
|
+
# Distribution name is `haze-agent`, not `haze`: the `haze` name is already
|
|
4
|
+
# taken on PyPI (verified 2026-08-29, HTTP 200). The CLI command is still
|
|
5
|
+
# `haze` -- console-script names are independent of the distribution name, so
|
|
6
|
+
# `uv tool install haze-agent` gives you a `haze` binary.
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "haze-agent"
|
|
10
|
+
version = "0.1.0"
|
|
11
|
+
description = "Haze -- pool your own machines into a private compute network"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.12,<3.14"
|
|
14
|
+
license = { text = "MIT" }
|
|
15
|
+
authors = [{ name = "Rahul Akshath" }]
|
|
16
|
+
keywords = ["distributed-computing", "resource-sharing", "lan", "self-hosted"]
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
# The loopback dashboard API. Bound to 127.0.0.1 only -- see api/app.py for
|
|
20
|
+
# why that binding is load-bearing rather than a default.
|
|
21
|
+
"fastapi>=0.115.0",
|
|
22
|
+
"uvicorn[standard]>=0.32.0",
|
|
23
|
+
"pydantic>=2.9.0",
|
|
24
|
+
"pydantic-settings>=2.6.0",
|
|
25
|
+
# CLI. typer rather than click directly: the `haze devnet up -n 4` style
|
|
26
|
+
# subcommand tree is what typer is good at, and it is click underneath.
|
|
27
|
+
"typer>=0.15.0",
|
|
28
|
+
# Identity + TLS certificates (M1). One audited dependency covers Ed25519,
|
|
29
|
+
# X.509 generation and HKDF, which is why PyNaCl is deliberately absent --
|
|
30
|
+
# we never need the Ed25519->X25519 conversion that only PyNaCl exposes,
|
|
31
|
+
# because TLS 1.3 negotiates its own ephemeral X25519 inside the handshake.
|
|
32
|
+
"cryptography>=50.0.1",
|
|
33
|
+
# mDNS/DNS-SD discovery (M2). Effectively maintained by the Home Assistant
|
|
34
|
+
# core team; does NOT use D-Bus, so it works on a NAS with no Avahi.
|
|
35
|
+
"zeroconf>=0.150.3",
|
|
36
|
+
# Resource probing (M2). Per-core CPU, RAM, disk, network counters.
|
|
37
|
+
"psutil>=6.1.0",
|
|
38
|
+
# Local state: paired peers, jobs, caps. aiosqlite because the API layer is
|
|
39
|
+
# async and a blocking sqlite3 call would stall the telemetry WebSocket.
|
|
40
|
+
"sqlalchemy[asyncio]>=2.0.36",
|
|
41
|
+
"aiosqlite>=0.20.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
# NVIDIA telemetry. An extra, not a core dependency: the package pulls CUDA
|
|
46
|
+
# metadata and is useless on the ~half of plausible Haze nodes that are Macs or
|
|
47
|
+
# AMD boxes. probe/gpu_nvidia.py imports it lazily and degrades to "no GPU".
|
|
48
|
+
nvidia = ["nvidia-ml-py>=12.560.30"]
|
|
49
|
+
|
|
50
|
+
dev = [
|
|
51
|
+
"pytest>=8.3.0",
|
|
52
|
+
"pytest-asyncio>=0.24.0",
|
|
53
|
+
# Starlette's TestClient transport. httpx2, not httpx: starlette warns on
|
|
54
|
+
# every import that the httpx 0.x backend is deprecated.
|
|
55
|
+
"httpx2>=2.12.0",
|
|
56
|
+
"ruff>=0.8.0",
|
|
57
|
+
"mypy>=1.13.0",
|
|
58
|
+
# psutil ships no py.typed; without stubs mypy strict fails on every probe.
|
|
59
|
+
"types-psutil>=6.1.0",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[project.scripts]
|
|
63
|
+
haze = "haze.cli:main"
|
|
64
|
+
|
|
65
|
+
[project.urls]
|
|
66
|
+
Homepage = "https://github.com/CodeMaster747/Haze"
|
|
67
|
+
Repository = "https://github.com/CodeMaster747/Haze"
|
|
68
|
+
|
|
69
|
+
[build-system]
|
|
70
|
+
requires = ["hatchling>=1.25"]
|
|
71
|
+
build-backend = "hatchling.build"
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
[tool.hatch.build.targets.wheel]
|
|
76
|
+
packages = ["src/haze"]
|
|
77
|
+
# src/haze/webui/ is the built React dashboard, copied in by `make build-web`.
|
|
78
|
+
# It is gitignored (it is a build artifact of web/, not a source of truth), and
|
|
79
|
+
# hatchling honours .gitignore by default -- so without this line the wheel
|
|
80
|
+
# would ship an agent with no UI. This is the single most breakable line in
|
|
81
|
+
# the file; `make verify-wheel` exists to catch it.
|
|
82
|
+
artifacts = ["src/haze/webui/**"]
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
[tool.ruff]
|
|
87
|
+
line-length = 100
|
|
88
|
+
target-version = "py312"
|
|
89
|
+
src = ["src", "tests"]
|
|
90
|
+
|
|
91
|
+
[tool.ruff.lint]
|
|
92
|
+
# `×` in user-facing output ("relative speed ×4.2") is a deliberate
|
|
93
|
+
# typographic choice, as are the dashes and curly quotes. These rules exist
|
|
94
|
+
# to catch a Cyrillic `a` smuggled into an identifier, not to force ASCII on
|
|
95
|
+
# prose a human reads.
|
|
96
|
+
allowed-confusables = ["×", "−", "—", "–", "“", "”", "’", "→", "·"]
|
|
97
|
+
select = [
|
|
98
|
+
"E", "W", # pycodestyle
|
|
99
|
+
"F", # pyflakes
|
|
100
|
+
"I", # isort
|
|
101
|
+
"N", # pep8-naming
|
|
102
|
+
"UP", # pyupgrade
|
|
103
|
+
"B", # bugbear
|
|
104
|
+
"C4", # comprehensions
|
|
105
|
+
"SIM", # simplify
|
|
106
|
+
"RUF", # ruff-specific
|
|
107
|
+
"ASYNC", # async correctness -- matters a lot here, the agent is all asyncio
|
|
108
|
+
"S", # bandit security -- this project executes subprocesses, so keep it on
|
|
109
|
+
"DTZ", # naive datetime usage
|
|
110
|
+
]
|
|
111
|
+
ignore = [
|
|
112
|
+
"E501", # line length is the formatter's job
|
|
113
|
+
"B008", # FastAPI's Depends() in argument defaults is the framework idiom
|
|
114
|
+
"S101", # assert is how pytest works
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
[tool.ruff.lint.per-file-ignores]
|
|
118
|
+
# S603: the integration test spawns `python -m haze` with a fixed argv.
|
|
119
|
+
# S310: it drives the local agent over a hardcoded http://127.0.0.1 URL.
|
|
120
|
+
# C408: dict(...) reads better than a brace literal for keyword-style test
|
|
121
|
+
# fixtures that are then splatted over with overrides.
|
|
122
|
+
"**/tests/*" = ["S101", "S105", "S106", "S311", "S603", "S310", "S108", "C408"]
|
|
123
|
+
|
|
124
|
+
[tool.ruff.lint.isort]
|
|
125
|
+
known-first-party = ["haze"]
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
[tool.mypy]
|
|
130
|
+
python_version = "3.12"
|
|
131
|
+
strict = true
|
|
132
|
+
warn_unreachable = true
|
|
133
|
+
plugins = ["pydantic.mypy"]
|
|
134
|
+
|
|
135
|
+
[[tool.mypy.overrides]]
|
|
136
|
+
# Ships no py.typed. Imported lazily in probe/gpu_nvidia.py behind a try/except.
|
|
137
|
+
module = ["pynvml", "pynvml.*"]
|
|
138
|
+
ignore_missing_imports = true
|
|
139
|
+
|
|
140
|
+
[[tool.mypy.overrides]]
|
|
141
|
+
module = "tests.*"
|
|
142
|
+
disallow_untyped_defs = false
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
[tool.pytest.ini_options]
|
|
147
|
+
testpaths = ["tests"]
|
|
148
|
+
asyncio_mode = "auto"
|
|
149
|
+
addopts = "-q --strict-markers"
|
|
150
|
+
markers = [
|
|
151
|
+
"lan: requires a second real machine on the LAN; skipped in CI",
|
|
152
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Haze -- pool your own machines into a private compute network."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
# Bumped independently of __version__. Two agents refuse to talk if their
|
|
6
|
+
# PROTOCOL_VERSION differs, so an old node on the LAN fails loudly at the
|
|
7
|
+
# handshake instead of subtly misparsing a frame three messages later.
|
|
8
|
+
PROTOCOL_VERSION = 1
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""`python -m haze`.
|
|
2
|
+
|
|
3
|
+
Routes through the same entry point as the `haze` console script, so both
|
|
4
|
+
present recoverable errors the same way. They diverged once: the console script
|
|
5
|
+
was fixed to show a clean message and `python -m haze` still dumped a
|
|
6
|
+
traceback, which the integration tests use and so would have kept passing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from haze.cli import main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The loopback dashboard API. Bound to 127.0.0.1 only -- never 0.0.0.0."""
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""The loopback FastAPI application.
|
|
2
|
+
|
|
3
|
+
Architecture note -- why the dashboard is served from here rather than from the
|
|
4
|
+
deployed Firebase site:
|
|
5
|
+
|
|
6
|
+
A public https origin can no longer reach a local agent. Safari hard-blocks
|
|
7
|
+
https -> http://127.0.0.1 as mixed content with no user override (WebKit bug
|
|
8
|
+
171934, open since 2017). Chrome 142 (Oct 2025) began prompting for Local
|
|
9
|
+
Network Access on any public-origin request to loopback/RFC1918/.local, and
|
|
10
|
+
Chrome 147 (Apr 2026) extended that gate to WebSockets, closing the one
|
|
11
|
+
remaining workaround.
|
|
12
|
+
|
|
13
|
+
Same-address-space requests are explicitly exempt. So the agent serves the SPA
|
|
14
|
+
itself and the browser only ever talks to one origin: http://127.0.0.1:<port>.
|
|
15
|
+
That single decision removes CORS, mixed content, the LNA prompt and certificate
|
|
16
|
+
warnings from the project simultaneously. It is also what Syncthing (8384),
|
|
17
|
+
Jellyfin (8096), Home Assistant (8123) and Ollama (11434) all do.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import AsyncIterator
|
|
23
|
+
from contextlib import asynccontextmanager
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from fastapi import APIRouter, Depends, FastAPI
|
|
27
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
28
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
29
|
+
from starlette.requests import Request
|
|
30
|
+
from starlette.responses import Response
|
|
31
|
+
from starlette.staticfiles import StaticFiles
|
|
32
|
+
from starlette.websockets import WebSocket
|
|
33
|
+
|
|
34
|
+
import haze
|
|
35
|
+
from haze import log, runtime
|
|
36
|
+
from haze.api import routes_jobs, routes_pairing, security, ws
|
|
37
|
+
from haze.config import Config
|
|
38
|
+
|
|
39
|
+
_log = log.get("api.app")
|
|
40
|
+
|
|
41
|
+
WEBUI_DIR = Path(__file__).resolve().parent.parent / "webui"
|
|
42
|
+
|
|
43
|
+
_PLACEHOLDER = """<!doctype html><meta charset=utf-8>
|
|
44
|
+
<title>Haze - dashboard not built</title>
|
|
45
|
+
<style>body{font:15px/1.6 ui-monospace,Menlo,monospace;background:#0b0b0d;color:#f4f4f5;
|
|
46
|
+
padding:3rem;max-width:44rem;margin:auto}code{background:#1a1a1f;padding:.15rem .4rem;
|
|
47
|
+
border-radius:4px}a{color:#7c5cff}</style>
|
|
48
|
+
<h1>Haze agent is running</h1>
|
|
49
|
+
<p>The API is live, but the dashboard bundle has not been built into this
|
|
50
|
+
install yet.</p>
|
|
51
|
+
<p>From the repo root:</p>
|
|
52
|
+
<pre><code>make build-web</code></pre>
|
|
53
|
+
<p>Then restart the agent. The API itself is already usable:
|
|
54
|
+
<code>GET /api/v1/node</code>.</p>
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _SecurityHeaders(BaseHTTPMiddleware):
|
|
59
|
+
"""Headers that matter for a loopback server reachable from any web page.
|
|
60
|
+
|
|
61
|
+
Note what is *absent*: there is no CORSMiddleware and no
|
|
62
|
+
Access-Control-Allow-Origin header anywhere in this app. That is
|
|
63
|
+
deliberate. The dashboard is same-origin, so it needs no CORS grant, and
|
|
64
|
+
adding one would hand cross-origin read access to exactly the attacker this
|
|
65
|
+
server has to keep out.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
|
|
69
|
+
response: Response = await call_next(request)
|
|
70
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
71
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
72
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
73
|
+
# The token can arrive as ?t=... on the first navigation; keep that URL
|
|
74
|
+
# out of any shared cache.
|
|
75
|
+
if request.url.path == "/" or request.url.path.endswith(".html"):
|
|
76
|
+
response.headers["Cache-Control"] = "no-store"
|
|
77
|
+
return response
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def create_app(
|
|
81
|
+
cfg: Config,
|
|
82
|
+
api_port: int,
|
|
83
|
+
*,
|
|
84
|
+
serve_peers: bool = True,
|
|
85
|
+
discover: bool = True,
|
|
86
|
+
profile: str | None = None,
|
|
87
|
+
) -> FastAPI:
|
|
88
|
+
"""Build the loopback app.
|
|
89
|
+
|
|
90
|
+
``serve_peers=False`` skips binding the node-to-node listener, which is what
|
|
91
|
+
the test suite wants: it exercises the API without claiming a LAN port that
|
|
92
|
+
a real agent (or a parallel test) might already hold.
|
|
93
|
+
"""
|
|
94
|
+
security.configure(cfg.dashboard_token, api_port)
|
|
95
|
+
|
|
96
|
+
@asynccontextmanager
|
|
97
|
+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
|
98
|
+
agent = await runtime.start(
|
|
99
|
+
cfg, serve_peers=serve_peers, discover=discover, profile=profile
|
|
100
|
+
)
|
|
101
|
+
app.state.agent = agent
|
|
102
|
+
|
|
103
|
+
hub = ws.TelemetryHub(agent.probe)
|
|
104
|
+
await hub.start()
|
|
105
|
+
app.state.hub = hub
|
|
106
|
+
|
|
107
|
+
_log.info("dashboard %s", f"http://127.0.0.1:{api_port}/")
|
|
108
|
+
try:
|
|
109
|
+
yield
|
|
110
|
+
finally:
|
|
111
|
+
await hub.stop()
|
|
112
|
+
await runtime.stop(agent)
|
|
113
|
+
|
|
114
|
+
app = FastAPI(
|
|
115
|
+
title="Haze Node Agent",
|
|
116
|
+
version=haze.__version__,
|
|
117
|
+
lifespan=lifespan,
|
|
118
|
+
# No interactive docs: they are an unauthenticated surface that
|
|
119
|
+
# enumerates every route for any page that gets past the origin check.
|
|
120
|
+
docs_url=None,
|
|
121
|
+
redoc_url=None,
|
|
122
|
+
openapi_url=None,
|
|
123
|
+
)
|
|
124
|
+
app.add_middleware(_SecurityHeaders)
|
|
125
|
+
|
|
126
|
+
api = APIRouter(prefix="/api/v1", dependencies=[Depends(security.require_api_auth)])
|
|
127
|
+
|
|
128
|
+
@api.get("/health")
|
|
129
|
+
async def health() -> JSONResponse:
|
|
130
|
+
return JSONResponse({"ok": True, "version": haze.__version__,
|
|
131
|
+
"protocol": haze.PROTOCOL_VERSION})
|
|
132
|
+
|
|
133
|
+
@api.get("/node")
|
|
134
|
+
async def node() -> JSONResponse:
|
|
135
|
+
"""This node's own identity and current state."""
|
|
136
|
+
hub: ws.TelemetryHub = app.state.hub
|
|
137
|
+
agent: runtime.Agent | None = getattr(app.state, "agent", None)
|
|
138
|
+
return JSONResponse(
|
|
139
|
+
{
|
|
140
|
+
"name": cfg.node_name,
|
|
141
|
+
"node_id": agent.node_id if agent else None,
|
|
142
|
+
"short_id": agent.identity.short_id if agent else None,
|
|
143
|
+
"simulated": agent.simulated if agent else False,
|
|
144
|
+
"api_port": api_port,
|
|
145
|
+
"node_port": cfg.node_port,
|
|
146
|
+
"version": haze.__version__,
|
|
147
|
+
"snapshot": hub.latest(),
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
api.include_router(routes_pairing.router)
|
|
152
|
+
api.include_router(routes_jobs.router)
|
|
153
|
+
app.include_router(api)
|
|
154
|
+
|
|
155
|
+
@app.websocket("/ws")
|
|
156
|
+
async def telemetry(socket: WebSocket) -> None:
|
|
157
|
+
auth = await security.authorise_websocket(socket)
|
|
158
|
+
if not auth.allowed:
|
|
159
|
+
return # authorise_websocket already closed the socket
|
|
160
|
+
await socket.accept(subprotocol=auth.subprotocol)
|
|
161
|
+
hub: ws.TelemetryHub = app.state.hub
|
|
162
|
+
agent: runtime.Agent | None = getattr(app.state, "agent", None)
|
|
163
|
+
await hub.serve(socket, agent)
|
|
164
|
+
|
|
165
|
+
# --- static SPA, mounted last so it never shadows /api or /ws ------------
|
|
166
|
+
if (WEBUI_DIR / "index.html").is_file():
|
|
167
|
+
assets = WEBUI_DIR / "assets"
|
|
168
|
+
if assets.is_dir():
|
|
169
|
+
app.mount("/assets", StaticFiles(directory=assets), name="assets")
|
|
170
|
+
|
|
171
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
172
|
+
async def spa(path: str) -> Response:
|
|
173
|
+
# Serve a real file if one exists (favicon, manifest), otherwise
|
|
174
|
+
# hand back index.html so client-side routes deep-link correctly.
|
|
175
|
+
candidate = (WEBUI_DIR / path).resolve()
|
|
176
|
+
if path and candidate.is_file() and candidate.is_relative_to(WEBUI_DIR):
|
|
177
|
+
return FileResponse(candidate)
|
|
178
|
+
return FileResponse(WEBUI_DIR / "index.html")
|
|
179
|
+
else:
|
|
180
|
+
_log.warning("dashboard bundle missing at %s -- run `make build-web`", WEBUI_DIR)
|
|
181
|
+
|
|
182
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
183
|
+
async def placeholder(path: str) -> HTMLResponse:
|
|
184
|
+
return HTMLResponse(_PLACEHOLDER)
|
|
185
|
+
|
|
186
|
+
return app
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def serve(cfg: Config, api_port: int, *, profile: str | None = None) -> None:
|
|
190
|
+
"""Run uvicorn bound to the 127.0.0.1 literal."""
|
|
191
|
+
import uvicorn
|
|
192
|
+
|
|
193
|
+
config = uvicorn.Config(
|
|
194
|
+
create_app(cfg, api_port, profile=profile),
|
|
195
|
+
# NEVER 0.0.0.0. This server can execute subprocesses; exposing it to
|
|
196
|
+
# the LAN would hand that to anyone on the same WiFi. Node-to-node
|
|
197
|
+
# traffic uses the mutually-authenticated TLS listener instead.
|
|
198
|
+
host="127.0.0.1",
|
|
199
|
+
port=api_port,
|
|
200
|
+
log_config=None,
|
|
201
|
+
access_log=False,
|
|
202
|
+
)
|
|
203
|
+
await uvicorn.Server(config).serve()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = ["WEBUI_DIR", "create_app", "serve"]
|