feat(workers): let a panel dial the GPU machine, so more than one person can use it

Remote workers connect outbound: the node dials the control plane, spends an
enrollment token, pins a certificate. That stays the default and is unchanged.

It is also structurally 1:1 — a worker process holds one endpoint, one pinned
certificate and one worker id — so a second person wanting the same GPU box has
to get shell access to it, repoint the start script at their own address and
restart, which disconnects whoever was using it. Sharing a GPU requires root on
it and evicts the incumbent, and no amount of UI work fixes that, because the
constraint is the shape of the connection.

This adds the other arrangement: the node listens, and any panel holding a key
connects to it, concurrently, with no shell access to the machine.

  * NodeService mirrors WorkerService. Transport roles invert; message roles do
    not — the node still sends WorkerMessage and the panel still sends
    ServerMessage, so every state machine on both sides is untouched. Register
    folds into the stream as the first exchange and reuses the existing
    request/response messages rather than growing parallel ones.
  * Keys are per panel, not per node. Revoking one person leaves everyone else
    connected; a shared key would be revoked by nobody and leave no record of
    who used it. Stored hashed, compared in constant time against every key so
    the reply time is not an oracle, and the plaintext exists exactly once.
  * Failed authentication is throttled per source address, so one stale
    bookmark cannot lock out a different panel.
  * A connection log records every attach, refusal and disconnect, and any
    session can be kicked. That is what replaces per-job approval, which would
    make a shared GPU unusable and train people to click yes.
  * Artifacts invert too: the panel pushes inputs before assigning, and fetches
    results after. The node stages both under one contained directory and
    trusts no id or filename off the wire.

Runs in plaintext by deliberate decision, recorded with its accepted risk in
docs/adr/inbound-node-mode.md, and scoped there to LAN and self-hosted use —
never a fleet transport, which goal_v2 B2/B5.2 still require to dial out.

Off by default, and bound to 127.0.0.1 until someone explicitly widens it.
This commit is contained in:
velixio
2026-08-11 22:06:12 +05:30
parent 673e544812
commit ef671de36e
13 changed files with 1796 additions and 82 deletions
+20
View File
@@ -0,0 +1,20 @@
"""Inbound mode: the control plane dials the node, instead of the reverse.
Default remote-worker mode is outbound — the node dials the control plane, which
is what a fleet needs (goal_v2.md B2/B5.2) and what works behind NAT with no
open ports. This package is the opposite arrangement, for two cases outbound
cannot serve:
* the node is reachable but the panel is not (the panel is the laptop);
* several people want to share one GPU box.
The second is the reason this exists. Outbound is structurally 1:1 — a worker
process holds exactly one endpoint, one pinned certificate and one worker id
(``agent.py``), so "let a colleague use the 4090" means SSHing into the box,
repointing it and restarting, which disconnects whoever had it. Inbound is 1:N
by construction: the node listens once and any panel holding a key connects,
concurrently, without shell access to the machine.
See ``docs/adr/inbound-node-mode.md`` for the security posture, which is
deliberately weaker than outbound's and is scoped to LAN / self-hosted use.
"""
+187
View File
@@ -0,0 +1,187 @@
"""Artifact staging for inbound mode, where the node cannot initiate a call.
Outbound moves bytes with RPCs the worker starts: it pulls inputs with
DownloadArtifact and pushes results with UploadResult. A node that was dialled
can do neither, so both directions are driven by the panel and the node's job
becomes staging:
* inputs — the panel pushes them (PushInput) *before* sending the
assignment, so by the time the executor asks for one it is already here;
* results — the node writes them here and names them in TaskResult; the panel
fetches them afterwards (FetchResult).
Everything lands under one directory that is resolved with the repo's existing
containment helpers. The wire supplies task ids, attempt ids and filenames, and
none of them are trusted: this is the same asymmetry that made B13 a real
arbitrary-write bug on the control-plane side, and it is not going to be
reintroduced from the other end.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import os
import shutil
import threading
import time
import uuid
from dataclasses import dataclass
from typing import Optional
from core.path_security import UnsafePath, resolve_within, safe_filename
from worker.protocol.gen import worker_v1_pb2 as pb
logger = logging.getLogger(__name__)
# Staged bytes are deleted once fetched, but a panel that dies mid-job leaves
# them behind. Anything older than this is swept on the next write.
_STALE_SECONDS = 24 * 60 * 60
@dataclass
class _Staged:
path: str
sha256: str
size_bytes: int
created_at: float
class ArtifactStore:
"""Node-side staging for one listener. Shared across panels."""
def __init__(self, root: str) -> None:
self._root = os.path.abspath(root)
self._lock = threading.Lock()
self._out: dict[str, _Staged] = {}
self._in: dict[str, _Staged] = {}
os.makedirs(self._root, exist_ok=True)
# ── Placement ─────────────────────────────────────────────────────────
def _place(self, kind: str, artifact_id: str, filename: str) -> str:
"""Build a path under the root from wire-supplied strings, safely.
`artifact_id` is minted here rather than taken from the wire, and the
filename is reduced to a bare portable name before it is joined. The
`resolve_within` call is the belt to that braces: it also rejects a
symlink planted inside the root, which validation of the components
alone cannot see.
"""
name = safe_filename(filename) if filename else ""
if not name:
name = "artifact.bin"
relative = os.path.join(kind, safe_filename(artifact_id), name)
return str(resolve_within(self._root, relative))
def _sweep_locked(self, now: float) -> None:
for index in (self._out, self._in):
for artifact_id, staged in list(index.items()):
if now - staged.created_at <= _STALE_SECONDS:
continue
index.pop(artifact_id, None)
_remove_quietly(staged.path)
# ── Results: node writes, panel fetches ───────────────────────────────
async def publish(
self, ref: pb.TaskRef, payload: bytes, meta: dict
) -> pb.ArtifactRef:
"""Stage a finished result and return the ref that names it."""
artifact_id = uuid.uuid4().hex
filename = str(meta.get("filename") or f"{ref.attempt_id}.wav")
digest = hashlib.sha256(payload).hexdigest()
path = self._place("out", artifact_id, filename)
def write() -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as handle:
handle.write(payload)
await asyncio.to_thread(write)
now = time.time()
with self._lock:
self._sweep_locked(now)
self._out[artifact_id] = _Staged(
path=path, sha256=digest, size_bytes=len(payload), created_at=now
)
return pb.ArtifactRef(
artifact_id=artifact_id,
task_id=ref.task_id,
attempt_id=ref.attempt_id,
filename=os.path.basename(path),
content_type=str(meta.get("content_type") or "audio/wav"),
size_bytes=len(payload),
sha256=digest,
)
def open_result(self, artifact_id: str) -> Optional[_Staged]:
with self._lock:
return self._out.get(artifact_id)
def result_fetched(self, artifact_id: str) -> None:
"""Drop a result once the panel has it. The panel's ack is the commit
point, so nothing is deleted on a partial read."""
with self._lock:
staged = self._out.pop(artifact_id, None)
if staged is not None:
_remove_quietly(staged.path)
# ── Inputs: panel pushes, node reads ──────────────────────────────────
def begin_input(self, ref: pb.ArtifactRef) -> str:
"""Reserve a staging path for an incoming push. Returns the path."""
artifact_id = safe_filename(ref.artifact_id or uuid.uuid4().hex)
path = self._place("in", artifact_id, ref.filename)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
def commit_input(self, ref: pb.ArtifactRef, path: str, digest: str, size: int) -> None:
now = time.time()
with self._lock:
self._sweep_locked(now)
self._in[ref.artifact_id] = _Staged(
path=path, sha256=digest, size_bytes=size, created_at=now
)
async def stage_in(self, ref: pb.ArtifactRef, destination: str) -> None:
"""Hand a previously pushed input to the executor.
Copied rather than moved: an attempt that is retried asks for the same
input again, and a move would make the second attempt fail with a
missing file that no log explains.
"""
with self._lock:
staged = self._in.get(ref.artifact_id)
if staged is None:
raise RuntimeError(
f"the control plane did not send input {ref.artifact_id or '(unnamed)'} "
"before assigning this task"
)
await asyncio.to_thread(shutil.copyfile, staged.path, destination)
def forget_input(self, artifact_id: str) -> None:
with self._lock:
staged = self._in.pop(artifact_id, None)
if staged is not None:
_remove_quietly(staged.path)
def purge(self) -> None:
"""Drop everything. Called when the listener stops."""
with self._lock:
staged = list(self._out.values()) + list(self._in.values())
self._out.clear()
self._in.clear()
for item in staged:
_remove_quietly(item.path)
def _remove_quietly(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
__all__ = ["ArtifactStore", "UnsafePath"]
+144
View File
@@ -0,0 +1,144 @@
"""What the node owner can see, and what they can do about it.
Inbound mode has no per-job approval prompt — the enable toggle is the consent
surface, and a prompt per job would make a shared GPU unusable. That trade only
holds if "who is using my machine right now" is answerable at a glance and
actable in one click. Without this, a key that leaked is invisible until the
electricity bill.
Events are kept in memory with a hard cap. Persisting them would put a record
of other people's activity on disk by default, which is a bigger promise than
this feature needs to make.
"""
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
from typing import Callable, Optional
# Enough to cover a working day of joins and drops without becoming a log file
# nobody rotates.
_MAX_EVENTS = 200
@dataclass
class Session:
"""One panel currently attached to this node."""
session_id: str
key_id: str
label: str
peer: str
connected_at: float
tasks_run: int = 0
# Set when the owner kicks the session. The stream loop checks it, so a
# disconnect that arrives mid-task ends cleanly rather than by exception.
disconnect_requested: bool = False
@dataclass
class Event:
at: float
kind: str # connected | disconnected | rejected | kicked
label: str = ""
peer: str = ""
detail: str = ""
class ConnectionLog:
def __init__(self, *, now: Optional[Callable[[], float]] = None) -> None:
self._now = now or time.time
self._lock = threading.Lock()
self._sessions: dict[str, Session] = {}
self._events: deque[Event] = deque(maxlen=_MAX_EVENTS)
# ── Sessions ──────────────────────────────────────────────────────────
def opened(self, *, session_id: str, key_id: str, label: str, peer: str) -> Session:
session = Session(
session_id=session_id,
key_id=key_id,
label=label,
peer=peer,
connected_at=self._now(),
)
with self._lock:
self._sessions[session_id] = session
self._events.append(
Event(at=session.connected_at, kind="connected", label=label, peer=peer)
)
return session
def closed(self, session_id: str, *, detail: str = "") -> None:
with self._lock:
session = self._sessions.pop(session_id, None)
if session is None:
return
self._events.append(
Event(
at=self._now(),
kind="kicked" if session.disconnect_requested else "disconnected",
label=session.label,
peer=session.peer,
detail=detail,
)
)
def rejected(self, *, peer: str, detail: str) -> None:
"""A refused attempt is the event that matters most and the one a
success-only log would omit entirely."""
with self._lock:
self._events.append(
Event(at=self._now(), kind="rejected", peer=peer, detail=detail)
)
def task_started(self, session_id: str) -> None:
with self._lock:
session = self._sessions.get(session_id)
if session is not None:
session.tasks_run += 1
def kick(self, session_id: str) -> bool:
"""Ask a session to end. Returns False if it already went away."""
with self._lock:
session = self._sessions.get(session_id)
if session is None:
return False
session.disconnect_requested = True
return True
def disconnect_requested(self, session_id: str) -> bool:
with self._lock:
session = self._sessions.get(session_id)
return session is not None and session.disconnect_requested
# ── Reporting ─────────────────────────────────────────────────────────
def snapshot(self) -> dict:
with self._lock:
return {
"sessions": [
{
"session_id": s.session_id,
"key_id": s.key_id,
"label": s.label,
"peer": s.peer,
"connected_at": s.connected_at,
"tasks_run": s.tasks_run,
}
for s in sorted(self._sessions.values(), key=lambda s: s.connected_at)
],
"events": [
{
"at": e.at,
"kind": e.kind,
"label": e.label,
"peer": e.peer,
"detail": e.detail,
}
for e in reversed(self._events)
],
}
+121
View File
@@ -0,0 +1,121 @@
"""The single copy-pasteable string that joins a panel to a node.
Host, port and key are three things to transcribe and three ways to fail with
an unhelpful error, and the failures are hard to tell apart from the outside: a
typo'd port and a wrong key both surface as "cannot connect". Collapsing them
into one artifact with one copy button removes the whole class.
ovnode://ovnode_<secret>@192.168.0.110:7444
Deliberately URL-shaped so it survives being pasted into a chat window, and
deliberately not an http(s) URL so no browser or link scanner treats it as
something to fetch — a credential in a URL that something prefetches is a
credential in somebody's access log.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Optional
from urllib.parse import unquote, urlsplit
from worker.inbound.keys import KEY_PREFIX
SCHEME = "ovnode"
# Deliberately permissive about the host (IPv4, IPv6, DNS name, .local) and
# strict about the key, because a malformed key is the recoverable mistake and
# an unusual-looking host usually is not a mistake at all.
_KEY_RE = re.compile(rf"^{re.escape(KEY_PREFIX)}[A-Za-z0-9_-]{{16,128}}$")
class InvalidConnectionString(ValueError):
"""Raised with a message written for the person who pasted it."""
@dataclass(frozen=True)
class Connection:
host: str
port: int
secret: str
@property
def endpoint(self) -> str:
"""host:port, bracketing IPv6 the way gRPC's resolver expects."""
if ":" in self.host and not self.host.startswith("["):
return f"[{self.host}]:{self.port}"
return f"{self.host}:{self.port}"
def redacted(self) -> str:
"""Safe to log. Keeps enough of the key to tell two panels apart."""
return f"{SCHEME}://{self.secret[: len(KEY_PREFIX) + 4]}…@{self.endpoint}"
def format_connection(*, host: str, port: int, secret: str) -> str:
if ":" in host and not host.startswith("["):
host = f"[{host}]"
return f"{SCHEME}://{secret}@{host}:{port}"
def parse_connection(text: str) -> Connection:
"""Parse a pasted connection string, or explain what is wrong with it."""
cleaned = (text or "").strip()
if not cleaned:
raise InvalidConnectionString("Paste the connection string from the GPU machine.")
# A bare host:port is the most likely near-miss — someone copies the
# address out of the UI and leaves the key behind. Naming that is far more
# use than "invalid connection string".
if "://" not in cleaned:
raise InvalidConnectionString(
"That looks like an address without a key. Copy the whole "
f"{SCHEME}://… string from the GPU machine's Settings → Remote workers."
)
parts = urlsplit(cleaned)
if parts.scheme != SCHEME:
raise InvalidConnectionString(
f"Expected a {SCHEME}:// connection string, but got {parts.scheme}://."
)
# urlsplit puts the credential in `username` only when an `@` is present;
# without one the key would be silently read as the hostname and the error
# would come out as a DNS failure.
if parts.username is None:
raise InvalidConnectionString(
"That connection string has no key in it. Copy the whole string, "
"including the part before the @."
)
secret = unquote(parts.username)
if not _KEY_RE.match(secret):
hint = (
" Enrollment tokens (ovw_…) are for the other direction, where the "
"GPU machine connects to you."
if secret.startswith("ovw_")
else ""
)
raise InvalidConnectionString(f"That key is not in the expected format.{hint}")
try:
host, port = parts.hostname, parts.port
except ValueError as exc:
# urlsplit raises rather than returning None for a non-numeric or
# out-of-range port.
raise InvalidConnectionString("That port number is not valid.") from exc
if not host:
raise InvalidConnectionString("That connection string has no address in it.")
if not port:
raise InvalidConnectionString(
"That connection string has no port in it. It should end in :port."
)
return Connection(host=host, port=port, secret=secret)
def try_parse(text: str) -> Optional[Connection]:
try:
return parse_connection(text)
except InvalidConnectionString:
return None
+233
View File
@@ -0,0 +1,233 @@
"""Per-panel API keys for inbound mode, and the throttle that protects them.
One key per panel, never one key for the node. A single shared key means
revoking one person kicks everybody and forces a re-paste on every machine, so
in practice nobody revokes and the credential outlives the reason it was
issued. Per-key costs nothing extra at issue time and is painful to retrofit,
because a shared key leaves no record of who used it.
Keys are stored hashed. The plaintext exists exactly once, in the response to
the issuing call, and is unrecoverable afterwards — the node cannot show a key
again later, only replace it.
"""
from __future__ import annotations
import json
import logging
import os
import secrets
import threading
import time
from dataclasses import asdict, dataclass
from typing import Optional
from worker.identity import constant_time_equals, hash_secret
logger = logging.getLogger(__name__)
# Distinguishes an inbound panel key from the `ovw_` enrollment token used by
# outbound mode. They are never interchangeable and the prefix makes a
# pasted-the-wrong-one mistake diagnosable instead of just "invalid".
KEY_PREFIX = "ovnode_"
# 32 bytes. The same size as the enrollment-token secret, and the reason
# `hash_secret` may be a plain SHA-256 rather than a password KDF.
_KEY_BYTES = 32
# Failed-auth throttle. A key is a bearer credential with no second factor, so
# the only thing standing between a LAN attacker and unlimited guesses is this.
# The window is per source address: one panel typing a stale key must not lock
# out a different panel with a good one.
_MAX_FAILURES = 5
_LOCKOUT_SECONDS = 60.0
_FAILURE_WINDOW_SECONDS = 300.0
@dataclass
class PanelKey:
"""One panel's admission credential. The secret itself is not in here."""
key_id: str
label: str
secret_hash: str
created_at: float
last_seen_at: float = 0.0
last_seen_peer: str = ""
revoked: bool = False
def public(self) -> dict:
"""The shape the UI sees. Deliberately has no field for the secret."""
data = asdict(self)
data.pop("secret_hash")
return data
@dataclass
class _Failures:
count: int = 0
first_at: float = 0.0
locked_until: float = 0.0
@dataclass
class IssuedKey:
"""The one and only time the plaintext exists outside the caller's hands."""
key: PanelKey
secret: str
class KeyStore:
"""Thread-safe, file-backed store of per-panel keys.
Backed by a plain JSON file rather than the settings store because the
settings store is read by the UI process and synced into places a
credential hash has no business being.
"""
def __init__(self, path: str, *, now: Optional[callable] = None) -> None:
self._path = path
self._now = now or time.time
self._lock = threading.Lock()
self._keys: dict[str, PanelKey] = {}
self._failures: dict[str, _Failures] = {}
self._load()
# ── Persistence ───────────────────────────────────────────────────────
def _load(self) -> None:
try:
with open(self._path, encoding="utf-8") as fh:
raw = json.load(fh)
except (FileNotFoundError, PermissionError):
return
except (json.JSONDecodeError, UnicodeDecodeError):
# A corrupt file must not take the node down, but it must also not
# silently become "no keys configured" — that reads to the user as
# "my keys vanished" with no cause anywhere.
logger.error(
"The inbound key file at %s is unreadable and was ignored. "
"Existing panels cannot connect until a key is re-issued.",
self._path,
)
return
for entry in raw.get("keys", []):
try:
key = PanelKey(**entry)
except TypeError:
continue
self._keys[key.key_id] = key
def _save_locked(self) -> None:
directory = os.path.dirname(os.path.abspath(self._path))
os.makedirs(directory, exist_ok=True)
payload = json.dumps(
{"keys": [asdict(k) for k in self._keys.values()]}, indent=2
).encode("utf-8")
tmp = f"{self._path}.tmp"
# 0600 from creation, never a world-readable moment — the same idiom
# `identity.save_worker_key` uses for the Ed25519 private key.
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, payload)
finally:
os.close(fd)
os.replace(tmp, self._path)
try:
os.chmod(self._path, 0o600)
except OSError:
# Windows and some network filesystems do not honour POSIX modes.
pass
# ── Issue and revoke ──────────────────────────────────────────────────
def issue(self, label: str) -> IssuedKey:
"""Mint a key for one panel. The secret is returned exactly once."""
secret = KEY_PREFIX + secrets.token_urlsafe(_KEY_BYTES)
now = self._now()
key = PanelKey(
# Derived from the secret's hash, not from a counter: it identifies
# the key in logs without being a second thing to store, and cannot
# be used to reconstruct the secret.
key_id=hash_secret(secret)[:12],
label=label.strip() or "Panel",
secret_hash=hash_secret(secret),
created_at=now,
)
with self._lock:
self._keys[key.key_id] = key
self._save_locked()
return IssuedKey(key=key, secret=secret)
def revoke(self, key_id: str) -> bool:
"""Revoke one panel's key. Others keep working — that is the point."""
with self._lock:
key = self._keys.get(key_id)
if key is None or key.revoked:
return False
key.revoked = True
self._save_locked()
return True
def list_keys(self) -> list[dict]:
with self._lock:
return [k.public() for k in sorted(self._keys.values(), key=lambda k: k.created_at)]
def any_active(self) -> bool:
with self._lock:
return any(not k.revoked for k in self._keys.values())
# ── Authentication ────────────────────────────────────────────────────
def locked_out(self, peer: str) -> bool:
with self._lock:
record = self._failures.get(peer)
return record is not None and record.locked_until > self._now()
def authenticate(self, secret: str, *, peer: str = "") -> Optional[PanelKey]:
"""Return the matching live key, or None.
Compares against every stored key in constant time and does not stop at
the first match. Short-circuiting would make the reply time a function
of how many keys are configured and which one matched — a slow oracle,
but an oracle.
"""
now = self._now()
with self._lock:
record = self._failures.get(peer)
if record is not None and record.locked_until > now:
return None
candidate = hash_secret(secret) if secret else ""
matched: Optional[PanelKey] = None
for key in self._keys.values():
if key.revoked or not candidate:
continue
if constant_time_equals(key.secret_hash, candidate):
matched = key
if matched is None:
self._record_failure_locked(peer, now)
return None
self._failures.pop(peer, None)
matched.last_seen_at = now
matched.last_seen_peer = peer
self._save_locked()
return matched
def _record_failure_locked(self, peer: str, now: float) -> None:
record = self._failures.get(peer)
if record is None or now - record.first_at > _FAILURE_WINDOW_SECONDS:
record = _Failures(count=0, first_at=now)
self._failures[peer] = record
record.count += 1
if record.count >= _MAX_FAILURES:
record.locked_until = now + _LOCKOUT_SECONDS
logger.warning(
"Refusing inbound connections from %s for %.0fs after %d failed keys.",
peer or "an unknown address",
_LOCKOUT_SECONDS,
record.count,
)
+364
View File
@@ -0,0 +1,364 @@
"""The node's inbound listener: a gRPC server hosting NodeService.
Off by default. Enabling it is the consent surface — there is no per-job
approval prompt, because a prompt per job makes a shared GPU unusable and
trains people to click yes. What replaces it is visibility: every attach,
refusal and disconnect is in the connection log, and any session can be kicked.
Binds to 127.0.0.1 unless the user separately and explicitly widens it. That
default is nearly useless on its own, which is the point: reaching a node from
another machine should be a decision someone made, not a side effect of turning
on a feature.
"""
from __future__ import annotations
import asyncio
import contextlib
import hashlib
import logging
import os
import uuid
from typing import Callable, Optional
import grpc
from worker.inbound.artifacts import ArtifactStore
from worker.inbound.connection_log import ConnectionLog
from worker.inbound.keys import KeyStore
from worker.protocol.gen import worker_v1_pb2 as pb
from worker.protocol.gen import worker_v1_pb2_grpc as pb_grpc
from worker.transport.client import MAX_MESSAGE_BYTES, WorkerClient
logger = logging.getLogger(__name__)
# The panel presents its key here. Lower-case because gRPC normalises metadata
# keys and a mixed-case constant silently never matches.
KEY_METADATA_KEY = "x-omnivoice-node-key"
DEFAULT_PORT = 7444
DEFAULT_BIND = "127.0.0.1"
_FETCH_CHUNK_BYTES = 1024 * 1024
def _peer_of(context) -> str:
"""A loggable source address. gRPC formats these as ipv4:1.2.3.4:5678."""
try:
raw = context.peer() or ""
except Exception:
return ""
for prefix in ("ipv4:", "ipv6:"):
if raw.startswith(prefix):
return raw[len(prefix) :]
return raw
class NodeServicer(pb_grpc.NodeServiceServicer):
"""Serves one node to any number of panels."""
def __init__(
self,
*,
keys: KeyStore,
log: ConnectionLog,
artifacts: ArtifactStore,
client_factory: Callable[[ArtifactStore], WorkerClient],
) -> None:
self._keys = keys
self._log = log
self._artifacts = artifacts
self._client_factory = client_factory
# ── Admission ─────────────────────────────────────────────────────────
def _authenticate(self, context) -> Optional[tuple[str, str]]:
"""Return (key_id, label) or None, logging the refusal either way."""
peer = _peer_of(context)
if self._keys.locked_out(peer):
self._log.rejected(peer=peer, detail="too many failed keys")
return None
metadata = {k.lower(): v for k, v in (context.invocation_metadata() or ())}
secret = metadata.get(KEY_METADATA_KEY, "")
key = self._keys.authenticate(secret, peer=peer)
if key is None:
self._log.rejected(
peer=peer, detail="no key" if not secret else "key not recognised"
)
return None
return key.key_id, key.label
# ── Attach ────────────────────────────────────────────────────────────
async def Attach(self, request_iterator, context):
admitted = self._authenticate(context)
if admitted is None:
await context.abort(
grpc.StatusCode.UNAUTHENTICATED,
"This GPU machine did not recognise that key.",
)
return
key_id, label = admitted
session_id = uuid.uuid4().hex
peer = _peer_of(context)
self._log.opened(session_id=session_id, key_id=key_id, label=label, peer=peer)
client = self._client_factory(self._artifacts)
reader: Optional[asyncio.Task] = None
try:
# The node speaks first even though the panel dialled: it is still
# the side with capabilities to declare, and the panel cannot
# schedule anything until it knows them.
yield pb.WorkerMessage(register=client.build_register_request())
first = await _next_frame(request_iterator)
if first is None or first.WhichOneof("payload") != "registered":
await context.abort(
grpc.StatusCode.FAILED_PRECONDITION,
"Expected the control plane to answer with a registration.",
)
return
await client.accept_registration(first.registered)
reader = asyncio.create_task(
self._pump_incoming(client, request_iterator, session_id)
)
while True:
if self._log.disconnect_requested(session_id):
yield pb.WorkerMessage(
goodbye=pb.WorkerGoodbye(
reason="The owner of this GPU machine ended the session."
)
)
return
# Bounded so a kick lands within a second even on an idle
# session, where nothing else would wake this loop.
try:
frame = await asyncio.wait_for(client.next_outbound(), timeout=1.0)
except asyncio.TimeoutError:
continue
yield frame
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Inbound session from %s ended: %s", peer or "a panel", exc)
finally:
if reader is not None:
reader.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await reader
await client.stop()
self._log.closed(session_id)
async def _pump_incoming(self, client: WorkerClient, request_iterator, session_id: str) -> None:
async for message in request_iterator:
kind = message.WhichOneof("payload")
if kind == "assignment":
self._log.task_started(session_id)
if kind == "registered":
# A second registration on a live stream is a control plane
# bug, not a re-handshake. Ignoring it is safer than adopting
# a new epoch mid-session and fencing the work in flight.
logger.warning("Ignoring a repeated registration on a live session")
continue
await client.handle_server_message(message)
# ── Artifacts ─────────────────────────────────────────────────────────
async def FetchResult(self, request, context):
if self._authenticate(context) is None:
await context.abort(
grpc.StatusCode.UNAUTHENTICATED,
"This GPU machine did not recognise that key.",
)
return
staged = self._artifacts.open_result(request.artifact_id)
if staged is None:
await context.abort(
grpc.StatusCode.NOT_FOUND, "That result is no longer on this machine."
)
return
offset = int(request.size_bytes or 0)
try:
with open(staged.path, "rb") as handle:
handle.seek(offset)
while True:
data = handle.read(_FETCH_CHUNK_BYTES)
if not data:
break
chunk = pb.ResultChunk(
ref=pb.ArtifactRef(
artifact_id=request.artifact_id,
task_id=request.task_id,
attempt_id=request.attempt_id,
filename=os.path.basename(staged.path),
size_bytes=staged.size_bytes,
sha256=staged.sha256,
),
offset=offset,
data=data,
)
offset += len(data)
chunk.last = offset >= staged.size_bytes
yield chunk
except OSError as exc:
await context.abort(grpc.StatusCode.INTERNAL, f"Could not read the result: {exc}")
return
# Dropped only after the last chunk left this process. A panel that
# dies mid-fetch can ask again; the stale sweep is what eventually
# reclaims it.
self._artifacts.result_fetched(request.artifact_id)
async def PushInput(self, request_iterator, context):
if self._authenticate(context) is None:
await context.abort(
grpc.StatusCode.UNAUTHENTICATED,
"This GPU machine did not recognise that key.",
)
return pb.ArtifactAck()
ref: Optional[pb.ArtifactRef] = None
path = ""
handle = None
digest = hashlib.sha256()
received = 0
committed = False
try:
async for chunk in request_iterator:
if ref is None:
ref = chunk.ref
path = self._artifacts.begin_input(ref)
handle = open(path, "wb")
if int(chunk.offset) != received:
return pb.ArtifactAck(
artifact_id=ref.artifact_id if ref else "",
bytes_received=received,
error=pb.Error(
code="OFFSET_MISMATCH",
message=f"expected offset {received}, got {chunk.offset}",
),
)
handle.write(chunk.data)
digest.update(chunk.data)
received += len(chunk.data)
if chunk.last:
committed = True
break
except Exception as exc:
return pb.ArtifactAck(
artifact_id=ref.artifact_id if ref else "",
bytes_received=received,
error=pb.Error(code="INPUT_WRITE_FAILED", message=str(exc)),
)
finally:
if handle is not None:
handle.close()
if ref is None or not committed:
# An iterator that simply ends is a truncated transfer, not a
# finished one. Committing here is exactly the bug that let a
# short upload be renamed into place and called done.
if path:
with contextlib.suppress(OSError):
os.remove(path)
return pb.ArtifactAck(
bytes_received=received,
error=pb.Error(
code="INPUT_INCOMPLETE",
message="the input ended before its final chunk",
),
)
actual = digest.hexdigest()
if ref.sha256 and actual != ref.sha256:
with contextlib.suppress(OSError):
os.remove(path)
return pb.ArtifactAck(
artifact_id=ref.artifact_id,
bytes_received=received,
error=pb.Error(
code="INPUT_CHECKSUM_MISMATCH",
message="the input did not match the checksum the control plane declared",
),
)
self._artifacts.commit_input(ref, path, actual, received)
return pb.ArtifactAck(
artifact_id=ref.artifact_id, bytes_received=received, committed=True
)
async def _next_frame(request_iterator):
try:
return await request_iterator.__anext__()
except StopAsyncIteration:
return None
class NodeListener:
"""Owns the gRPC server. Started only when the user turns inbound on."""
def __init__(
self,
*,
keys: KeyStore,
log: ConnectionLog,
artifacts: ArtifactStore,
client_factory: Callable[[ArtifactStore], WorkerClient],
) -> None:
self._servicer = NodeServicer(
keys=keys, log=log, artifacts=artifacts, client_factory=client_factory
)
self._artifacts = artifacts
self._server: Optional[grpc.aio.Server] = None
self._bound_port = 0
@property
def running(self) -> bool:
return self._server is not None
@property
def port(self) -> int:
"""The port actually bound, which is not always the one requested."""
return self._bound_port
async def start(self, *, host: str = DEFAULT_BIND, port: int = DEFAULT_PORT) -> int:
if self._server is not None:
return self._bound_port
server = grpc.aio.server(
options=[
("grpc.max_receive_message_length", MAX_MESSAGE_BYTES),
("grpc.max_send_message_length", MAX_MESSAGE_BYTES),
# A panel's channel pings on an idle Attach stream exactly as a
# worker's does outbound. Without these the server answers
# too_many_pings and evicts the healthy panels it was waiting
# for — the same eviction that cost this feature a day when the
# control plane did it.
("grpc.keepalive_permit_without_calls", 1),
("grpc.http2.min_ping_interval_without_data_ms", 20_000),
("grpc.http2.max_pings_without_data", 0),
]
)
pb_grpc.add_NodeServiceServicer_to_server(self._servicer, server)
# Plaintext by design; see docs/adr/inbound-node-mode.md. The API
# key is what admits a panel, and it is not confidential against
# anyone who can read this LAN segment.
bind = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}"
bound = server.add_insecure_port(bind)
if not bound:
raise RuntimeError(
f"Could not listen on {bind}. Another program may already be using that port."
)
await server.start()
self._server = server
self._bound_port = bound
logger.info("Inbound node listener accepting connections on %s", bind)
return bound
async def stop(self) -> None:
server, self._server = self._server, None
self._bound_port = 0
if server is not None:
await server.stop(grace=1.0)
self._artifacts.purge()
File diff suppressed because one or more lines are too long
+20 -4
View File
@@ -354,7 +354,7 @@ class DownloadProgress(_message.Message):
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., event_json: _Optional[str] = ...) -> None: ...
class WorkerMessage(_message.Message):
__slots__ = ("heartbeat", "accepted", "rejected", "model_loading", "started", "progress", "result", "failed", "cancel_ack", "capabilities", "goodbye", "pong", "download_progress")
__slots__ = ("heartbeat", "accepted", "rejected", "model_loading", "started", "progress", "result", "failed", "cancel_ack", "capabilities", "goodbye", "pong", "download_progress", "register")
HEARTBEAT_FIELD_NUMBER: _ClassVar[int]
ACCEPTED_FIELD_NUMBER: _ClassVar[int]
REJECTED_FIELD_NUMBER: _ClassVar[int]
@@ -368,6 +368,7 @@ class WorkerMessage(_message.Message):
GOODBYE_FIELD_NUMBER: _ClassVar[int]
PONG_FIELD_NUMBER: _ClassVar[int]
DOWNLOAD_PROGRESS_FIELD_NUMBER: _ClassVar[int]
REGISTER_FIELD_NUMBER: _ClassVar[int]
heartbeat: Heartbeat
accepted: TaskAccepted
rejected: TaskRejected
@@ -381,7 +382,8 @@ class WorkerMessage(_message.Message):
goodbye: WorkerGoodbye
pong: Pong
download_progress: DownloadProgress
def __init__(self, heartbeat: _Optional[_Union[Heartbeat, _Mapping]] = ..., accepted: _Optional[_Union[TaskAccepted, _Mapping]] = ..., rejected: _Optional[_Union[TaskRejected, _Mapping]] = ..., model_loading: _Optional[_Union[TaskModelLoading, _Mapping]] = ..., started: _Optional[_Union[TaskStarted, _Mapping]] = ..., progress: _Optional[_Union[TaskProgress, _Mapping]] = ..., result: _Optional[_Union[TaskResult, _Mapping]] = ..., failed: _Optional[_Union[TaskFailed, _Mapping]] = ..., cancel_ack: _Optional[_Union[TaskCancelAck, _Mapping]] = ..., capabilities: _Optional[_Union[CapabilityUpdate, _Mapping]] = ..., goodbye: _Optional[_Union[WorkerGoodbye, _Mapping]] = ..., pong: _Optional[_Union[Pong, _Mapping]] = ..., download_progress: _Optional[_Union[DownloadProgress, _Mapping]] = ...) -> None: ...
register: RegisterRequest
def __init__(self, heartbeat: _Optional[_Union[Heartbeat, _Mapping]] = ..., accepted: _Optional[_Union[TaskAccepted, _Mapping]] = ..., rejected: _Optional[_Union[TaskRejected, _Mapping]] = ..., model_loading: _Optional[_Union[TaskModelLoading, _Mapping]] = ..., started: _Optional[_Union[TaskStarted, _Mapping]] = ..., progress: _Optional[_Union[TaskProgress, _Mapping]] = ..., result: _Optional[_Union[TaskResult, _Mapping]] = ..., failed: _Optional[_Union[TaskFailed, _Mapping]] = ..., cancel_ack: _Optional[_Union[TaskCancelAck, _Mapping]] = ..., capabilities: _Optional[_Union[CapabilityUpdate, _Mapping]] = ..., goodbye: _Optional[_Union[WorkerGoodbye, _Mapping]] = ..., pong: _Optional[_Union[Pong, _Mapping]] = ..., download_progress: _Optional[_Union[DownloadProgress, _Mapping]] = ..., register: _Optional[_Union[RegisterRequest, _Mapping]] = ...) -> None: ...
class Deadlines(_message.Message):
__slots__ = ("accept_seconds", "model_load_seconds", "execution_seconds", "progress_lease_seconds", "result_delivery_seconds")
@@ -501,7 +503,7 @@ class Shutdown(_message.Message):
def __init__(self, envelope: _Optional[_Union[Envelope, _Mapping]] = ..., reason: _Optional[str] = ...) -> None: ...
class ServerMessage(_message.Message):
__slots__ = ("assignment", "cancel", "result_ack", "config", "ping", "drain", "shutdown", "prewarm")
__slots__ = ("assignment", "cancel", "result_ack", "config", "ping", "drain", "shutdown", "prewarm", "registered")
ASSIGNMENT_FIELD_NUMBER: _ClassVar[int]
CANCEL_FIELD_NUMBER: _ClassVar[int]
RESULT_ACK_FIELD_NUMBER: _ClassVar[int]
@@ -510,6 +512,7 @@ class ServerMessage(_message.Message):
DRAIN_FIELD_NUMBER: _ClassVar[int]
SHUTDOWN_FIELD_NUMBER: _ClassVar[int]
PREWARM_FIELD_NUMBER: _ClassVar[int]
REGISTERED_FIELD_NUMBER: _ClassVar[int]
assignment: TaskAssignment
cancel: TaskCancel
result_ack: ResultAckMessage
@@ -518,7 +521,8 @@ class ServerMessage(_message.Message):
drain: Drain
shutdown: Shutdown
prewarm: PrewarmRequest
def __init__(self, assignment: _Optional[_Union[TaskAssignment, _Mapping]] = ..., cancel: _Optional[_Union[TaskCancel, _Mapping]] = ..., result_ack: _Optional[_Union[ResultAckMessage, _Mapping]] = ..., config: _Optional[_Union[ConfigUpdate, _Mapping]] = ..., ping: _Optional[_Union[Ping, _Mapping]] = ..., drain: _Optional[_Union[Drain, _Mapping]] = ..., shutdown: _Optional[_Union[Shutdown, _Mapping]] = ..., prewarm: _Optional[_Union[PrewarmRequest, _Mapping]] = ...) -> None: ...
registered: RegisterResponse
def __init__(self, assignment: _Optional[_Union[TaskAssignment, _Mapping]] = ..., cancel: _Optional[_Union[TaskCancel, _Mapping]] = ..., result_ack: _Optional[_Union[ResultAckMessage, _Mapping]] = ..., config: _Optional[_Union[ConfigUpdate, _Mapping]] = ..., ping: _Optional[_Union[Ping, _Mapping]] = ..., drain: _Optional[_Union[Drain, _Mapping]] = ..., shutdown: _Optional[_Union[Shutdown, _Mapping]] = ..., prewarm: _Optional[_Union[PrewarmRequest, _Mapping]] = ..., registered: _Optional[_Union[RegisterResponse, _Mapping]] = ...) -> None: ...
class ArtifactRef(_message.Message):
__slots__ = ("artifact_id", "task_id", "attempt_id", "filename", "content_type", "size_bytes", "sha256", "session_token")
@@ -577,3 +581,15 @@ class ResultAck(_message.Message):
committed: bool
error: Error
def __init__(self, artifact_id: _Optional[str] = ..., bytes_received: _Optional[int] = ..., committed: _Optional[bool] = ..., error: _Optional[_Union[Error, _Mapping]] = ...) -> None: ...
class ArtifactAck(_message.Message):
__slots__ = ("artifact_id", "bytes_received", "committed", "error")
ARTIFACT_ID_FIELD_NUMBER: _ClassVar[int]
BYTES_RECEIVED_FIELD_NUMBER: _ClassVar[int]
COMMITTED_FIELD_NUMBER: _ClassVar[int]
ERROR_FIELD_NUMBER: _ClassVar[int]
artifact_id: str
bytes_received: int
committed: bool
error: Error
def __init__(self, artifact_id: _Optional[str] = ..., bytes_received: _Optional[int] = ..., committed: _Optional[bool] = ..., error: _Optional[_Union[Error, _Mapping]] = ...) -> None: ...
@@ -234,3 +234,217 @@ class WorkerService:
timeout,
metadata,
_registered_method=True)
class NodeServiceStub:
"""Hosted by the NODE, dialled by the CONTROL PLANE — the mirror image of
WorkerService above, for the deployment where the node cannot dial out (or
where several panels share one GPU box; see docs/adr/inbound-node-mode.md).
TRANSPORT roles invert here. MESSAGE roles do NOT: the node still sends
WorkerMessage (heartbeats, capabilities, progress, results) and the control
plane still sends ServerMessage (assignments, cancels, acks). Every state
machine on both sides is therefore unchanged, and that is the whole point of
mirroring the service instead of inventing a second protocol. Read the field
names as "what this side says", never as "who called whom".
This service is for LAN / self-hosted use only and is never a fleet
transport: goal_v2.md B2/B5.2 require hosted workers to dial out, and nothing
here relaxes that.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.Attach = channel.stream_stream(
'/omnivoice.worker.v1.NodeService/Attach',
request_serializer=worker__v1__pb2.ServerMessage.SerializeToString,
response_deserializer=worker__v1__pb2.WorkerMessage.FromString,
_registered_method=True)
self.FetchResult = channel.unary_stream(
'/omnivoice.worker.v1.NodeService/FetchResult',
request_serializer=worker__v1__pb2.ArtifactRef.SerializeToString,
response_deserializer=worker__v1__pb2.ResultChunk.FromString,
_registered_method=True)
self.PushInput = channel.stream_unary(
'/omnivoice.worker.v1.NodeService/PushInput',
request_serializer=worker__v1__pb2.ArtifactChunk.SerializeToString,
response_deserializer=worker__v1__pb2.ArtifactAck.FromString,
_registered_method=True)
class NodeServiceServicer:
"""Hosted by the NODE, dialled by the CONTROL PLANE — the mirror image of
WorkerService above, for the deployment where the node cannot dial out (or
where several panels share one GPU box; see docs/adr/inbound-node-mode.md).
TRANSPORT roles invert here. MESSAGE roles do NOT: the node still sends
WorkerMessage (heartbeats, capabilities, progress, results) and the control
plane still sends ServerMessage (assignments, cancels, acks). Every state
machine on both sides is therefore unchanged, and that is the whole point of
mirroring the service instead of inventing a second protocol. Read the field
names as "what this side says", never as "who called whom".
This service is for LAN / self-hosted use only and is never a fleet
transport: goal_v2.md B2/B5.2 require hosted workers to dial out, and nothing
here relaxes that.
"""
def Attach(self, request_iterator, context):
"""The control plane opens this; the node answers. Carries the same frames
Control does, plus Register folded in as the first exchange — a node being
dialled cannot also expose a unary Register the way a control plane does,
and a separate round trip would leave the stream ambiguous until it
finished.
The node's first frame MUST be `register` and the panel's first frame MUST
be `registered`. Note that the node still speaks first despite the panel
having opened the call: it is still the side with capabilities to declare.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def FetchResult(self, request, context):
"""Artifact out, pulled instead of pushed: mirrors UploadResult. The node has
no way to call the panel, so the panel fetches a finished result once the
node reports it. Resumable via ArtifactRef-scoped offsets, same as
UploadResult.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def PushInput(self, request_iterator, context):
"""Artifact in, pushed instead of pulled: mirrors DownloadArtifact.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_NodeServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'Attach': grpc.stream_stream_rpc_method_handler(
servicer.Attach,
request_deserializer=worker__v1__pb2.ServerMessage.FromString,
response_serializer=worker__v1__pb2.WorkerMessage.SerializeToString,
),
'FetchResult': grpc.unary_stream_rpc_method_handler(
servicer.FetchResult,
request_deserializer=worker__v1__pb2.ArtifactRef.FromString,
response_serializer=worker__v1__pb2.ResultChunk.SerializeToString,
),
'PushInput': grpc.stream_unary_rpc_method_handler(
servicer.PushInput,
request_deserializer=worker__v1__pb2.ArtifactChunk.FromString,
response_serializer=worker__v1__pb2.ArtifactAck.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'omnivoice.worker.v1.NodeService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('omnivoice.worker.v1.NodeService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class NodeService:
"""Hosted by the NODE, dialled by the CONTROL PLANE — the mirror image of
WorkerService above, for the deployment where the node cannot dial out (or
where several panels share one GPU box; see docs/adr/inbound-node-mode.md).
TRANSPORT roles invert here. MESSAGE roles do NOT: the node still sends
WorkerMessage (heartbeats, capabilities, progress, results) and the control
plane still sends ServerMessage (assignments, cancels, acks). Every state
machine on both sides is therefore unchanged, and that is the whole point of
mirroring the service instead of inventing a second protocol. Read the field
names as "what this side says", never as "who called whom".
This service is for LAN / self-hosted use only and is never a fleet
transport: goal_v2.md B2/B5.2 require hosted workers to dial out, and nothing
here relaxes that.
"""
@staticmethod
def Attach(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_stream(
request_iterator,
target,
'/omnivoice.worker.v1.NodeService/Attach',
worker__v1__pb2.ServerMessage.SerializeToString,
worker__v1__pb2.WorkerMessage.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def FetchResult(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/omnivoice.worker.v1.NodeService/FetchResult',
worker__v1__pb2.ArtifactRef.SerializeToString,
worker__v1__pb2.ResultChunk.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def PushInput(request_iterator,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.stream_unary(
request_iterator,
target,
'/omnivoice.worker.v1.NodeService/PushInput',
worker__v1__pb2.ArtifactChunk.SerializeToString,
worker__v1__pb2.ArtifactAck.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+64
View File
@@ -39,6 +39,42 @@ service WorkerService {
rpc DownloadArtifact(ArtifactRef) returns (stream ArtifactChunk);
}
// Hosted by the NODE, dialled by the CONTROL PLANE — the mirror image of
// WorkerService above, for the deployment where the node cannot dial out (or
// where several panels share one GPU box; see docs/adr/inbound-node-mode.md).
//
// TRANSPORT roles invert here. MESSAGE roles do NOT: the node still sends
// WorkerMessage (heartbeats, capabilities, progress, results) and the control
// plane still sends ServerMessage (assignments, cancels, acks). Every state
// machine on both sides is therefore unchanged, and that is the whole point of
// mirroring the service instead of inventing a second protocol. Read the field
// names as "what this side says", never as "who called whom".
//
// This service is for LAN / self-hosted use only and is never a fleet
// transport: goal_v2.md B2/B5.2 require hosted workers to dial out, and nothing
// here relaxes that.
service NodeService {
// The control plane opens this; the node answers. Carries the same frames
// Control does, plus Register folded in as the first exchange — a node being
// dialled cannot also expose a unary Register the way a control plane does,
// and a separate round trip would leave the stream ambiguous until it
// finished.
//
// The node's first frame MUST be `register` and the panel's first frame MUST
// be `registered`. Note that the node still speaks first despite the panel
// having opened the call: it is still the side with capabilities to declare.
rpc Attach(stream ServerMessage) returns (stream WorkerMessage);
// Artifact out, pulled instead of pushed: mirrors UploadResult. The node has
// no way to call the panel, so the panel fetches a finished result once the
// node reports it. Resumable via ArtifactRef-scoped offsets, same as
// UploadResult.
rpc FetchResult(ArtifactRef) returns (stream ResultChunk);
// Artifact in, pushed instead of pulled: mirrors DownloadArtifact.
rpc PushInput(stream ArtifactChunk) returns (ArtifactAck);
}
// ── Common ─────────────────────────────────────────────────────────────────
// Stamped on every task-scoped message so superseded work can be fenced.
@@ -310,6 +346,16 @@ message WorkerMessage {
WorkerGoodbye goodbye = 11;
Pong pong = 12;
DownloadProgress download_progress = 13;
// Inbound mode only (NodeService.Attach): the node's opening frame, sent
// as soon as the panel's call is authenticated. Reuses RegisterRequest
// verbatim rather than defining a parallel message, so version
// negotiation, capability reporting and in-flight recovery behave
// identically in both modes — a second shape here would be a second thing
// to keep in step forever.
//
// Note the direction: the node describes ITSELF, exactly as it does when
// it dials out. Only who opened the TCP connection changed.
RegisterRequest register = 15;
}
reserved 14; // future streaming frame
}
@@ -391,6 +437,14 @@ message ServerMessage {
Drain drain = 6;
Shutdown shutdown = 7;
PrewarmRequest prewarm = 8;
// Inbound mode only (NodeService.Attach): the panel's answer to the node's
// `register` frame, carrying the session token and epoch. See
// `WorkerMessage.register` for why RegisterResponse is reused as-is.
//
// The API key authenticating the panel travels in the call's metadata,
// never in a frame: a credential in the stream would be copied into every
// protocol trace and every debug log that dumps one.
RegisterResponse registered = 9;
}
}
@@ -428,3 +482,13 @@ message ResultAck {
bool committed = 3;
Error error = 4;
}
// NodeService.PushInput's reply. Deliberately the same shape as ResultAck so
// the resume logic on either side reads identically regardless of which
// direction the bytes were travelling.
message ArtifactAck {
string artifact_id = 1;
uint64 bytes_received = 2;
bool committed = 3;
Error error = 4;
}
+82 -42
View File
@@ -254,11 +254,18 @@ class WorkerClient:
cancel: Optional[Callable[[str], Awaitable[None]]] = None,
capability_probe: Optional[Callable[[], list[dict]]] = None,
on_registered: Optional[Callable[[str], None]] = None,
artifacts: Optional["ArtifactTransport"] = None,
) -> None:
self.config = config
self._execute = execute
self._cancel = cancel
self._capability_probe = capability_probe
# Outbound mode moves artifacts with RPCs this side initiates
# (UploadResult / DownloadArtifact), which is only possible because
# this side dialled. In inbound mode the node cannot call the panel at
# all, so both directions are driven from the panel and this hook
# swaps in the staging that makes that work. None means outbound.
self._artifacts = artifacts
# Lets the agent persist the server-assigned id. Without it a restarted
# worker signs its challenge with an empty worker_id, the signature
# never matches, and reconnecting needs a fresh enrollment token —
@@ -323,25 +330,10 @@ class WorkerClient:
async with self._channel() as channel:
stub = pb_grpc.WorkerServiceStub(channel)
response = await self._register(stub)
if response.error.code:
# An authentication or version refusal is not something a
# retry loop fixes; say so rather than reconnecting forever.
raise RuntimeError(f"{response.error.code}: {response.error.message}")
self._epoch = response.session_epoch
self._session_token = response.session_token
self.config.worker_id = response.worker_id
# The token is spent; every later connection proves key possession.
self.config.enrollment_token = ""
if self._on_registered is not None:
try:
self._on_registered(response.worker_id)
except Exception:
logger.warning("Could not persist the worker id", exc_info=True)
authoritative = {ref.attempt_id for ref in response.authoritative_in_flight}
await self._cancel_zombies(authoritative)
await self._redeliver_pending()
# An authentication or version refusal is not something a retry
# loop fixes; `accept_registration` raises rather than reconnecting
# forever.
await self.accept_registration(response)
metadata = ((SESSION_METADATA_KEY, self._session_token),)
stream = stub.Control(self._outbound(), metadata=metadata)
@@ -361,7 +353,17 @@ class WorkerClient:
# instead of the honest "no session".
self._stub = None
async def _register(self, stub) -> pb.RegisterResponse:
# ── Session seams ─────────────────────────────────────────────────────
#
# Outbound owns its whole connection: dial, Register, stream, repeat. A
# node being dialled owns none of that — the gRPC servicer does — so these
# three expose the parts that are about the PROTOCOL rather than about who
# opened the socket. Outbound calls them through `_connect_once` exactly as
# before; inbound calls them from the Attach handler. Neither mode gets its
# own copy of registration, zombie reconciliation or redelivery.
def build_register_request(self) -> pb.RegisterRequest:
"""This worker's self-description. Identical in both modes."""
challenge = identity.new_challenge()
nonce = identity.new_challenge()
signature = self.config.keypair.sign(
@@ -375,30 +377,58 @@ class WorkerClient:
capabilities = (
self._capability_probe() if self._capability_probe else self.config.capabilities
)
return await stub.Register(
pb.RegisterRequest(
envelope=pb.Envelope(sequence=self._epoch),
protocol_version_min=PROTOCOL_VERSION,
protocol_version_max=PROTOCOL_VERSION,
enrollment_token=self.config.enrollment_token,
worker_id=self.config.worker_id,
public_key=self.config.keypair.public_bytes(),
challenge=challenge,
challenge_signature=signature,
nonce=nonce,
key_id=self.config.keypair.key_id,
host=codec.host_to_pb(self.config.host or describe_host()),
capabilities=[codec.capability_to_pb(c) for c in capabilities],
max_concurrent_tasks=self.config.max_concurrent_tasks,
in_flight=[
codec.task_ref(t.split("/")[0], t.split("/")[1], self._epoch)
for t in self._running
],
completed_unacked=[p.ref for p in self._pending.values()],
features=sorted(REQUIRED_FEATURES),
)
return pb.RegisterRequest(
envelope=pb.Envelope(sequence=self._epoch),
protocol_version_min=PROTOCOL_VERSION,
protocol_version_max=PROTOCOL_VERSION,
enrollment_token=self.config.enrollment_token,
worker_id=self.config.worker_id,
public_key=self.config.keypair.public_bytes(),
challenge=challenge,
challenge_signature=signature,
nonce=nonce,
key_id=self.config.keypair.key_id,
host=codec.host_to_pb(self.config.host or describe_host()),
capabilities=[codec.capability_to_pb(c) for c in capabilities],
max_concurrent_tasks=self.config.max_concurrent_tasks,
in_flight=[
codec.task_ref(t.split("/")[0], t.split("/")[1], self._epoch)
for t in self._running
],
completed_unacked=[p.ref for p in self._pending.values()],
features=sorted(REQUIRED_FEATURES),
)
async def accept_registration(self, response: pb.RegisterResponse) -> None:
"""Adopt the control plane's answer and recover in-flight state."""
if response.error.code:
raise RuntimeError(f"{response.error.code}: {response.error.message}")
self._epoch = response.session_epoch
self._session_token = response.session_token
self.config.worker_id = response.worker_id
# The token is spent; every later connection proves key possession.
self.config.enrollment_token = ""
if self._on_registered is not None:
try:
self._on_registered(response.worker_id)
except Exception:
logger.warning("Could not persist the worker id", exc_info=True)
authoritative = {ref.attempt_id for ref in response.authoritative_in_flight}
await self._cancel_zombies(authoritative)
await self._redeliver_pending()
async def next_outbound(self) -> pb.WorkerMessage:
"""The next frame this worker wants to send."""
return await self._outbox.get()
async def handle_server_message(self, message: pb.ServerMessage) -> None:
await self._on_server_message(message)
async def _register(self, stub) -> pb.RegisterResponse:
return await stub.Register(self.build_register_request())
# ── Outbound ──────────────────────────────────────────────────────────
async def _outbound(self):
@@ -749,6 +779,11 @@ class WorkerClient:
refuse a transfer that arrives short or corrupted instead of renaming a
truncated file into place and calling the task done.
"""
if self._artifacts is not None:
# Inbound: nothing is pushed. The result is staged here and the
# panel fetches it after the TaskResult frame names it.
return await self._artifacts.publish(ref, payload, meta)
stub = self._stub
if stub is None:
raise RuntimeError("no session is established")
@@ -868,6 +903,11 @@ class WorkerClient:
async def _fetch_input(self, ref: pb.ArtifactRef, destination: str) -> None:
"""Download one declared input with authenticated, ordered chunks."""
if self._artifacts is not None:
# Inbound: the panel pushed this before it sent the assignment, so
# there is nothing to pull — only a staged file to hand over.
return await self._artifacts.stage_in(ref, destination)
if self._stub is None:
raise RuntimeError("no session is established")
request = pb.ArtifactRef()
+121
View File
@@ -0,0 +1,121 @@
# Inbound node mode: the panel dials the GPU machine
**Status:** accepted
**Date:** 2026-08-11
## Context
Remote workers connect *outbound*: the node dials the control plane, presents a
single-use enrollment token, pins the control plane's certificate on first use,
and proves possession of an Ed25519 key on every reconnect. That is the right
default. It works behind NAT with no open ports, it is what a hosted fleet
needs (`remote/goal_v2.md` B2), and its security posture is strong.
It is also structurally **1:1**. A worker process holds exactly one endpoint,
one pinned certificate and one worker id (`backend/worker/agent.py`). So when a
second person wants to use the same GPU box, the only route is:
1. get shell access to the machine — someone else's machine;
2. mint an enrollment token on *their* panel;
3. edit the start script to point at their address;
4. restart, which **disconnects whoever was using it**.
Sharing a GPU therefore requires root on it and evicts the incumbent. For a
household or a small team with one 4090, that is the difference between a
feature and a thing nobody uses. No amount of polish on the outbound flow fixes
it, because the constraint is the shape of the connection, not the UI.
## Decision
Add an **inbound** mode, alongside outbound, in which the node listens and any
panel holding an API key connects to it. Outbound remains the default and is
unchanged.
Specifics:
- **New gRPC service `NodeService`, hosted by the node** — `Attach`,
`FetchResult`, `PushInput` — mirroring `WorkerService`. **Transport roles
invert; message roles do not.** The node still sends `WorkerMessage`
(heartbeats, capabilities, progress, results) and the panel still sends
`ServerMessage` (assignments, cancels, acks), so every state machine on both
sides is untouched. `Register` folds into the stream as the first exchange,
reusing `RegisterRequest`/`RegisterResponse` verbatim rather than defining
parallel messages.
- **Per-panel API keys, not one node key.** Stored as SHA-256 hashes with a
constant-time compare; the plaintext exists once, in the issuing response.
- **Failed-auth throttle**, per source address, so one stale bookmark cannot
lock out a different panel.
- **Concurrent panels are allowed.** Two panels may each believe they hold the
node's free slot and both dispatch; the worker's `WORKER_AT_CAPACITY` reject
is authoritative (`goal_v2.md` B5.5) and the loser retries. Contention
degrades to a queue, not to corruption. Serialising panels instead would
recreate the eviction problem in a nicer wrapper.
- **Ed25519 identity is retained.** The API key admits a *panel to the node*;
the node still proves itself to the panel with its keypair. The key is
admission, never identity.
- **A visible connection log with a kick action** replaces per-job approval.
- **Default bind `127.0.0.1`.** Listening on other interfaces is a separate,
explicit setting, and the bound address is shown in the UI.
- **Off by default.** Enabling it is the consent surface.
## The part we are choosing to accept: no TLS
Inbound runs in plaintext. The owner decided this deliberately after the risk
was raised. Recording it here so it is a decision with a rationale rather than
an omission someone discovers later.
What that means, stated plainly:
- The API key crosses the network in gRPC metadata **in the clear**. Anyone who
can read the LAN segment — or sits on the path — can lift it and use the GPU.
- There is no server authentication, so a machine that can answer at the node's
address can impersonate it and receive whatever the panel sends, **including
reference audio for voice cloning**.
- Rendered audio comes back in the clear.
Why it was accepted: the target is a trusted LAN or a self-hosted setup where
the alternative is people not using the feature at all. TLS here means either
a self-signed certificate the user must pin by hand — the step that already
makes outbound enrollment the hardest part of the flow — or a CA nobody has.
The judgement is that the honest, documented plaintext channel is better than a
pinning ceremony that gets abandoned halfway.
Consequences accepted with it:
- **This mode is for LAN and self-hosted use only, and is never a fleet
transport.** `goal_v2.md` B2 and B5.2 require hosted workers to dial out with
no inbound ports; nothing here relaxes that, and the hosted platform must not
inherit this path. See "Amendment" below.
- Binding to `0.0.0.0` on an untrusted network exposes a plaintext bearer
credential to that network. The UI says so at the point the bind is widened.
- If the threat model ever changes, TLS is additive: the listener already
terminates its own connections, so `add_secure_port` and a pinned certificate
slot in without touching the protocol.
## Amendment to goal_v2.md B5.2
B5.2 reads "Workers connect outbound; the control plane never dials in", and
B2 promises "no static IP, no inbound ports". Those remain true **for the
hosted fleet**, which is what they were written about. They are now scoped
statements rather than global ones: the OSS desktop product also supports an
inbound mode for locally-owned hardware, with the weaker posture documented
above. The conformance fixtures for the Go control plane must cover
`WorkerService` only.
## Alternatives rejected
- **Multi-endpoint dial-out** (the node dials N control planes). Preserves the
outbound principle and solves nothing: the second user still needs SSH access
to add their endpoint and still needs a token from their own panel. The
complexity being removed is "you need shell access to someone else's GPU
box", and this keeps all of it.
- **One shared node key.** Cheaper, and revoking it kicks everyone and forces a
re-paste on every machine — so in practice nobody revokes, and the credential
outlives the reason it was issued. A shared key also leaves no record of who
used it, which makes the connection log much less useful.
- **Per-job approval prompts.** Makes a shared GPU unusable and trains people
to click yes. Visibility plus a kick button is the better trade.
- **Reusing `WorkerService` with the panel as gRPC client.** Does not work: the
gRPC client sends the request-stream type, so the panel would be sending
`WorkerMessage`. Mirroring the service is what keeps the message roles — and
therefore every state machine — unchanged.
+186
View File
@@ -0,0 +1,186 @@
"""Admission for inbound mode: per-panel keys, hashing, throttling.
Inbound trades away the TLS pinning and single-use enrollment token that
outbound relies on (docs/adr/0002-inbound-node-mode.md), so the API key is the
whole of admission. These tests exist because everything that protects it —
hashing at rest, per-key revocation, the failed-auth throttle — is invisible in
normal use and would fail silently if it regressed.
"""
from __future__ import annotations
import json
import pytest
from worker.inbound import keys as keys_module
from worker.inbound.connection_string import (
InvalidConnectionString,
format_connection,
parse_connection,
)
from worker.inbound.keys import KEY_PREFIX, KeyStore
@pytest.fixture
def store(tmp_path):
return KeyStore(str(tmp_path / "inbound-keys.json"))
def test_the_plaintext_key_is_never_written_to_disk(store, tmp_path):
"""The node can replace a key but must never be able to show it again."""
issued = store.issue("Alice laptop")
on_disk = (tmp_path / "inbound-keys.json").read_text(encoding="utf-8")
assert issued.secret not in on_disk
assert issued.key.secret_hash in on_disk
# And nothing in the API hands it back either — a "reveal key" button is
# the feature this shape exists to make impossible to build by accident.
assert all("secret" not in row for row in store.list_keys())
def test_revoking_one_panel_leaves_the_others_working(store):
"""The whole reason keys are per-panel rather than one shared node key."""
alice = store.issue("Alice")
bob = store.issue("Bob")
assert store.revoke(alice.key.key_id) is True
assert store.authenticate(alice.secret, peer="10.0.0.1") is None
assert store.authenticate(bob.secret, peer="10.0.0.2") is not None
def test_a_wrong_key_is_throttled_before_it_can_be_guessed(store, monkeypatch):
"""A bearer credential with no second factor has only this between it and
unlimited LAN guesses."""
store.issue("Alice")
for _ in range(keys_module._MAX_FAILURES):
assert store.authenticate("ovnode_wrong", peer="10.0.0.9") is None
assert store.locked_out("10.0.0.9") is True
def test_one_panel_typing_a_stale_key_cannot_lock_out_another(store):
"""The throttle is per source address on purpose: a shared counter turns
one person's stale bookmark into an outage for everybody else."""
good = store.issue("Bob")
for _ in range(keys_module._MAX_FAILURES + 2):
store.authenticate("ovnode_wrong", peer="10.0.0.9")
assert store.locked_out("10.0.0.9") is True
assert store.locked_out("10.0.0.10") is False
assert store.authenticate(good.secret, peer="10.0.0.10") is not None
def test_a_locked_out_peer_is_refused_even_with_the_right_key(store):
"""Otherwise the throttle is decorative: an attacker who eventually
guesses correctly is admitted on the guess that succeeds."""
good = store.issue("Bob")
for _ in range(keys_module._MAX_FAILURES):
store.authenticate("ovnode_wrong", peer="10.0.0.9")
assert store.authenticate(good.secret, peer="10.0.0.9") is None
def test_an_empty_key_never_authenticates(store):
"""A missing metadata header arrives as "" and must not match a key whose
hash happens to be falsy-adjacent."""
store.issue("Alice")
assert store.authenticate("", peer="10.0.0.1") is None
def test_keys_survive_a_restart(store, tmp_path):
issued = store.issue("Alice")
reopened = KeyStore(str(tmp_path / "inbound-keys.json"))
assert reopened.authenticate(issued.secret, peer="10.0.0.1") is not None
def test_a_corrupt_key_file_is_reported_rather_than_read_as_no_keys(tmp_path, caplog):
"""Silently becoming "no keys configured" reads to the user as "my keys
vanished", with the cause nowhere."""
path = tmp_path / "inbound-keys.json"
path.write_text("{not json", encoding="utf-8")
with caplog.at_level("ERROR"):
store = KeyStore(str(path))
assert store.list_keys() == []
assert "unreadable" in caplog.text
def test_authentication_records_who_connected_and_from_where(store):
issued = store.issue("Alice laptop")
store.authenticate(issued.secret, peer="10.0.0.5")
row = store.list_keys()[0]
assert row["label"] == "Alice laptop"
assert row["last_seen_peer"] == "10.0.0.5"
assert row["last_seen_at"] > 0
# ── Connection string ──────────────────────────────────────────────────────
def test_the_connection_string_round_trips(store):
issued = store.issue("Alice")
text = format_connection(host="192.168.0.110", port=7444, secret=issued.secret)
parsed = parse_connection(text)
assert parsed.host == "192.168.0.110"
assert parsed.port == 7444
assert parsed.secret == issued.secret
assert parsed.endpoint == "192.168.0.110:7444"
def test_an_ipv6_node_is_bracketed_for_grpc():
"""gRPC's resolver reads an unbracketed IPv6 address as host:port and
fails on the wrong half of it."""
text = format_connection(host="fd00::1", port=7444, secret=KEY_PREFIX + "a" * 32)
assert parse_connection(text).endpoint == "[fd00::1]:7444"
def test_the_secret_never_appears_in_the_loggable_form():
connection = parse_connection(
format_connection(host="10.0.0.2", port=7444, secret=KEY_PREFIX + "s" * 40)
)
redacted = connection.redacted()
assert connection.secret not in redacted
assert "10.0.0.2:7444" in redacted
@pytest.mark.parametrize(
"text, expected",
[
("192.168.0.110:7444", "without a key"),
("ovnode://192.168.0.110:7444", "no key in it"),
("https://192.168.0.110:7444", "connection string"),
("ovnode://ovnode_short@10.0.0.1:7444", "not in the expected format"),
("", "Paste the connection string"),
],
)
def test_a_malformed_connection_string_says_what_is_wrong(text, expected):
"""Every one of these otherwise surfaces as "cannot connect", which is the
same thing a firewall, a wrong port and a dead node all say."""
with pytest.raises(InvalidConnectionString) as excinfo:
parse_connection(text)
assert expected in str(excinfo.value)
def test_pasting_an_outbound_enrollment_token_says_so():
"""The two credentials look alike and go in opposite directions; "invalid
key" would send someone hunting for a typo that is not there."""
with pytest.raises(InvalidConnectionString) as excinfo:
parse_connection("ovnode://ovw_" + "a" * 40 + "@10.0.0.1:7444")
assert "other direction" in str(excinfo.value)