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.
Files changed (87) hide show
  1. haze_agent-0.1.0/.gitignore +44 -0
  2. haze_agent-0.1.0/PKG-INFO +69 -0
  3. haze_agent-0.1.0/README.md +38 -0
  4. haze_agent-0.1.0/pyproject.toml +152 -0
  5. haze_agent-0.1.0/src/haze/__init__.py +8 -0
  6. haze_agent-0.1.0/src/haze/__main__.py +12 -0
  7. haze_agent-0.1.0/src/haze/api/__init__.py +1 -0
  8. haze_agent-0.1.0/src/haze/api/app.py +206 -0
  9. haze_agent-0.1.0/src/haze/api/routes_jobs.py +228 -0
  10. haze_agent-0.1.0/src/haze/api/routes_pairing.py +210 -0
  11. haze_agent-0.1.0/src/haze/api/security.py +128 -0
  12. haze_agent-0.1.0/src/haze/api/ws.py +142 -0
  13. haze_agent-0.1.0/src/haze/apiclient.py +85 -0
  14. haze_agent-0.1.0/src/haze/blobs/__init__.py +1 -0
  15. haze_agent-0.1.0/src/haze/blobs/transfer.py +135 -0
  16. haze_agent-0.1.0/src/haze/cli.py +166 -0
  17. haze_agent-0.1.0/src/haze/cli_devnet.py +117 -0
  18. haze_agent-0.1.0/src/haze/cli_jobs.py +328 -0
  19. haze_agent-0.1.0/src/haze/cli_pairing.py +241 -0
  20. haze_agent-0.1.0/src/haze/config.py +243 -0
  21. haze_agent-0.1.0/src/haze/db/__init__.py +1 -0
  22. haze_agent-0.1.0/src/haze/db/models.py +76 -0
  23. haze_agent-0.1.0/src/haze/db/peers.py +106 -0
  24. haze_agent-0.1.0/src/haze/db/session.py +76 -0
  25. haze_agent-0.1.0/src/haze/devnet/__init__.py +1 -0
  26. haze_agent-0.1.0/src/haze/devnet/profiles/builder.toml +18 -0
  27. haze_agent-0.1.0/src/haze/devnet/profiles/laptop.toml +20 -0
  28. haze_agent-0.1.0/src/haze/devnet/profiles/nas.toml +10 -0
  29. haze_agent-0.1.0/src/haze/devnet/profiles/workstation.toml +26 -0
  30. haze_agent-0.1.0/src/haze/devnet/supervisor.py +187 -0
  31. haze_agent-0.1.0/src/haze/discovery/__init__.py +1 -0
  32. haze_agent-0.1.0/src/haze/discovery/broadcast.py +151 -0
  33. haze_agent-0.1.0/src/haze/discovery/mdns.py +170 -0
  34. haze_agent-0.1.0/src/haze/discovery/registry.py +223 -0
  35. haze_agent-0.1.0/src/haze/discovery/types.py +67 -0
  36. haze_agent-0.1.0/src/haze/identity/__init__.py +1 -0
  37. haze_agent-0.1.0/src/haze/identity/certs.py +179 -0
  38. haze_agent-0.1.0/src/haze/identity/keys.py +150 -0
  39. haze_agent-0.1.0/src/haze/identity/nodeid.py +140 -0
  40. haze_agent-0.1.0/src/haze/jobs/__init__.py +1 -0
  41. haze_agent-0.1.0/src/haze/jobs/executor.py +500 -0
  42. haze_agent-0.1.0/src/haze/jobs/limits.py +192 -0
  43. haze_agent-0.1.0/src/haze/jobs/progress.py +252 -0
  44. haze_agent-0.1.0/src/haze/jobs/runtimes/__init__.py +29 -0
  45. haze_agent-0.1.0/src/haze/jobs/runtimes/base.py +89 -0
  46. haze_agent-0.1.0/src/haze/jobs/runtimes/blender.py +82 -0
  47. haze_agent-0.1.0/src/haze/jobs/runtimes/ffmpeg.py +104 -0
  48. haze_agent-0.1.0/src/haze/jobs/runtimes/hashbench.py +103 -0
  49. haze_agent-0.1.0/src/haze/jobs/spec.py +173 -0
  50. haze_agent-0.1.0/src/haze/log.py +43 -0
  51. haze_agent-0.1.0/src/haze/pairing/__init__.py +1 -0
  52. haze_agent-0.1.0/src/haze/pairing/manager.py +236 -0
  53. haze_agent-0.1.0/src/haze/pairing/sas.py +127 -0
  54. haze_agent-0.1.0/src/haze/probe/__init__.py +1 -0
  55. haze_agent-0.1.0/src/haze/probe/base.py +89 -0
  56. haze_agent-0.1.0/src/haze/probe/encoders.py +70 -0
  57. haze_agent-0.1.0/src/haze/probe/gpu.py +153 -0
  58. haze_agent-0.1.0/src/haze/probe/host.py +57 -0
  59. haze_agent-0.1.0/src/haze/probe/synthetic.py +171 -0
  60. haze_agent-0.1.0/src/haze/runtime.py +153 -0
  61. haze_agent-0.1.0/src/haze/scheduler/__init__.py +1 -0
  62. haze_agent-0.1.0/src/haze/scheduler/cluster.py +121 -0
  63. haze_agent-0.1.0/src/haze/scheduler/decide.py +281 -0
  64. haze_agent-0.1.0/src/haze/scheduler/model.py +143 -0
  65. haze_agent-0.1.0/src/haze/transport/__init__.py +1 -0
  66. haze_agent-0.1.0/src/haze/transport/client.py +349 -0
  67. haze_agent-0.1.0/src/haze/transport/frames.py +101 -0
  68. haze_agent-0.1.0/src/haze/transport/handshake.py +238 -0
  69. haze_agent-0.1.0/src/haze/transport/server.py +385 -0
  70. haze_agent-0.1.0/src/haze/transport/tls.py +161 -0
  71. haze_agent-0.1.0/src/haze/webui/assets/index-CtGvuqj-.css +1 -0
  72. haze_agent-0.1.0/src/haze/webui/assets/index-DMuCrmq-.js +226 -0
  73. haze_agent-0.1.0/src/haze/webui/assets/index-DMuCrmq-.js.map +1 -0
  74. haze_agent-0.1.0/src/haze/webui/index.html +20 -0
  75. haze_agent-0.1.0/tests/__init__.py +0 -0
  76. haze_agent-0.1.0/tests/conformance/scheduler_cases.json +1386 -0
  77. haze_agent-0.1.0/tests/conftest.py +45 -0
  78. haze_agent-0.1.0/tests/test_api_security.py +133 -0
  79. haze_agent-0.1.0/tests/test_discovery.py +109 -0
  80. haze_agent-0.1.0/tests/test_identity.py +156 -0
  81. haze_agent-0.1.0/tests/test_jobs.py +300 -0
  82. haze_agent-0.1.0/tests/test_pairing_integration.py +369 -0
  83. haze_agent-0.1.0/tests/test_probe.py +117 -0
  84. haze_agent-0.1.0/tests/test_resilience.py +189 -0
  85. haze_agent-0.1.0/tests/test_scheduler.py +232 -0
  86. haze_agent-0.1.0/tests/test_spa_serving.py +61 -0
  87. 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"]