Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,8 @@ asyncio.run(main())

### Listen port — default 9484, auto-increment (v0.7.5+)

The official IICP port **9484** is the default listen port (`IICP_PORT`, `--port`).
The unassigned project-default port **9484** is the default listen port
(`IICP_PORT`, `--port`); it is not an IANA-assigned IICP service port.
The `iicp-node` CLI auto-increments to the next free port when 9484 is already in
use, so you can run several nodes on one host without picking ports by hand — the
first binds 9484, the second 9485, the third 9486, and so on. Each node gets its
Expand All @@ -416,6 +417,26 @@ give it as-is (no auto-increment at the library level).

---

### Experimental native TCP boundary

Provider nodes serve the supported HTTP task path by default. Installing the
`iicp-tcp` extra does not mount or advertise the native TCP draft, which is
outside the coordinated stable and production support baseline. A direct
development endpoint requires both the extra and an explicit runtime opt-in:

```bash
python -m pip install 'iicp-client[iicp-tcp]'
IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP=1 iicp-node serve --node my-node
```

Automatic derivation accepts only direct `http://` endpoints and produces
plaintext `iicp://`. It never rewrites an `https://` endpoint to `iicpsec://`,
because an HTTPS reverse proxy or Quick Tunnel does not prove a native TLS
route. Generated launchd and systemd units omit the setting by default and
preserve it only when explicitly configured.

---

## Backends

A provider node forwards each task to an inference backend. The backend is selected
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ Documentation = "https://iicp.network/docs"
metrics = [
"prometheus-client>=0.20",
]
# Native IICP binary transport (port 9484, spec/iicp-framing.md). Optional
# because cbor2 has a C-extension build that can fail on some musl-based
# minimal containers — HTTP-only nodes don't need it.
# Experimental plaintext native IICP draft (provisional port 9484). Installing
# this extra does not mount or advertise it; provider nodes also require
# IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP=1. It is excluded from stable/production.
iicp-tcp = [
"cbor2>=5.4",
]
Expand Down
38 changes: 30 additions & 8 deletions src/iicp_client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ def _env_bool(name: str, default: bool = False) -> bool:
return value.strip().lower() in {"1", "true", "yes"}


def _explicit_bool_env(name: str) -> bool | None:
value = os.environ.get(name)
if value is None:
return None
normalized = value.strip().lower()
if normalized in {"1", "true", "yes"}:
return True
if normalized in {"0", "false", "no"}:
return False
raise ValueError(f"{name} must be one of 1/true/yes or 0/false/no")


def _managed_operator_decision(args, tunnel_preference, operator) -> tuple[bool, str]:
"""Evaluate the local managed profile before exposure or registration."""
from iicp_client.operator_profile import ManagedOperatorInput, evaluate_managed_operator
Expand Down Expand Up @@ -427,6 +439,10 @@ def _build_parser() -> argparse.ArgumentParser:
"Dead-state policy: IICP_TUNNEL_DEAD_POLICY=auto|retry|exit|log-only; "
"generated services set IICP_SUPERVISED=1. env: IICP_TUNNEL=1/0",
)
serve.epilog = (
"Experimental native TCP is disabled by default and excluded from stable/production claims. "
"For a direct development endpoint only: IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP=1."
)
serve.add_argument(
"--relay-capable",
action=argparse.BooleanOptionalAction,
Expand Down Expand Up @@ -1818,7 +1834,7 @@ async def _serve(args: argparse.Namespace) -> int:
return 2

# Resolve the actual listen port before NAT detection: start at the
# requested port (default 9484, the official IICP port) and auto-increment
# requested port (default 9484, the unassigned project convention) and auto-increment
# to the next free port. This keeps one port per node (multiple models on
# one node share it) while N nodes on one host each get a distinct port →
# distinct pinhole. Skipped when the operator supplies an explicit
Expand Down Expand Up @@ -2131,17 +2147,23 @@ async def _serve(args: argparse.Namespace) -> int:
logger.error(str(exc))
return 2

# #457 / ADR-040 — advertise the native IICP binary transport. serve() multiplexes it
# onto the SAME socket as HTTP (first-byte detection), so transport_endpoint shares the
# endpoint's host:port with the iicp:// scheme. Derived from the FINAL endpoint (after NAT
# profile application); register() only sends it when registering (skip_registration gates
# the non-routable case) → advertise-when-reachable. Opt out: IICP_DISABLE_NATIVE_TRANSPORT=1.
if not args.skip_registration and os.environ.get("IICP_DISABLE_NATIVE_TRANSPORT") != "1":
# Native TCP remains a development-only draft outside the coordinated
# stable support baseline. It must be explicitly enabled, and HTTPS must
# never be rewritten to iicpsec because this server has no native TLS path.
if _explicit_bool_env("IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP") is True:
from iicp_client.node import derive_native_endpoint

_native_ep = derive_native_endpoint(node._cfg.endpoint)
if _native_ep:
node._cfg.transport_endpoint = _native_ep
logger.warning(
"Experimental plaintext native TCP enabled; it is excluded from stable and production claims."
)
else:
logger.warning(
"Experimental native TCP was requested but no direct HTTP endpoint can be derived; "
"HTTPS/tunnel endpoints are not native TLS routes, so the listener remains disabled."
)

# #404 — register with bounded backoff retry. On persistent failure, pass an
# empty token (NOT None) so the heartbeat loop still starts and re-registers on
Expand Down Expand Up @@ -2621,7 +2643,7 @@ def _check_dependencies(backend_url: str) -> list[_DepIssue]:

# 2) Optional Python deps mapped to pip extras
optional = [
("cbor2", "iicp-tcp", "native IICP TCP transport (port 9484)"),
("cbor2", "iicp-tcp", "experimental native IICP TCP draft (disabled by default; not stable/production)"),
("upnpclient", "nat", "UPnP NAT detection + IPv6 firewall pinhole"),
("ifaddr", "nat", "interface enumeration for NAT detection"),
("prometheus_client", "metrics", "/metrics endpoint"),
Expand Down
8 changes: 6 additions & 2 deletions src/iicp_client/iicp_tcp.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# SPDX-License-Identifier: Apache-2.0
"""Native IICP binary transport (port 9484) — server + framing + cbor payloads.
"""Experimental native IICP binary transport — server + framing + CBOR payloads.

Provider nodes require the separate ``IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP=1``
runtime opt-in before mounting or advertising this plaintext development
binding. It is excluded from stable and production support claims.

Implements the wire side of spec/iicp-framing.md so a hybrid-client SDK node
can answer task CALLs over the native binary transport instead of (or in
Expand Down Expand Up @@ -58,7 +62,7 @@ def stable_task_message_type_error(msg_type: int) -> str | None:

Relay experiments retain 0x0B/0x0C on their dedicated transport. Those
bytes conflict with the inherited CONTROL/ADVERTISE registry and therefore
cannot enter a stable native task session.
cannot enter the bounded experimental native task profile.
"""
if msg_type in _STABLE_TASK_MESSAGE_TYPES:
return None
Expand Down
4 changes: 2 additions & 2 deletions src/iicp_client/nat_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,8 @@ async def detect_nat(
external_ip_probe_url: opt-in WAN-IP probe URL (e.g. api.ipify.org).
Used as a fallback when UPnP AddPortMapping succeeds but the IGD
refuses GetExternalIPAddress (FRITZ!Box auth-restricted case).
transport_port: optional native IICP TCP port (default 9484 per
spec/iicp-dir.md v0.7.0). When set AND distinct from `bind_port`,
transport_port: optional experimental native IICP TCP port. The stable
default is disabled. When set AND distinct from `bind_port`,
the detector asks UPnP to map BOTH ports and returns a
transport_endpoint URL alongside the HTTP public_endpoint.
"""
Expand Down
55 changes: 30 additions & 25 deletions src/iicp_client/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,17 +121,15 @@ async def _post_cip_receipt(


def derive_native_endpoint(endpoint: str) -> str | None:
"""#457 / ADR-040 — derive the native binary transport_endpoint from the HTTP `endpoint`.
"""Derive the experimental plaintext native endpoint from direct HTTP.

They share one host:port (serve() multiplexes both planes on one socket via first-byte
detection), so the native URI is the same authority with the ``iicp`` scheme (``iicpsec``
for TLS). Returns None if `endpoint` is not a parseable http(s) URL.
The maintained server has no native TLS terminator. An HTTPS reverse proxy
or tunnel therefore cannot be assumed to carry the binary protocol and is
never rewritten to ``iicpsec://`` automatically.
"""
parts = urlsplit(endpoint)
if parts.scheme == "http" and parts.netloc:
return f"iicp://{parts.netloc}"
if parts.scheme == "https" and parts.netloc:
return f"iicpsec://{parts.netloc}"
return None


Expand Down Expand Up @@ -280,10 +278,10 @@ class NodeConfig:
max_concurrent: int = 4
tokens_per_min: int = 10000
max_tokens: int = 8192
# spec/iicp-dir.md v0.7.0 — optional native IICP binary endpoint (ADR-040).
# Scheme MUST be iicp:// (plaintext) or iicpsec:// (TLS); default port 9484.
# When set, the directory persists it and clients SHOULD prefer it over
# `endpoint` for task CALLs. Leave None for HTTP-only operation.
# Experimental native IICP binary endpoint (ADR-040), disabled by default.
# iicp:// is plaintext development use; iicpsec:// requires a real native
# TLS terminator. Port 9484 is an unassigned project convention. When set,
# the directory persists it for explicitly enabled experimental peers.
transport_endpoint: str | None = None
# #331 Phase A.1 / ADR-041 — NAT-traversal observability fields surfaced
# to the directory in the register payload. Populated automatically by
Expand Down Expand Up @@ -1856,15 +1854,15 @@ def _json_response(self, status: int, body: bytes, cors: bool = True) -> None:
self.end_headers()
self.wfile.write(body)

# #457 / ADR-040 — single-port multiplexer: the HTTP control plane and the native
# IICP binary transport share ONE socket. Each accepted connection's first 4 bytes
# are peeked (MSG_PEEK, non-consuming): the IICP frame magic "IICP" routes to the
# native handler (the SAME backend task handler as HTTP), anything else (an HTTP
# request line) to the BaseHTTPRequestHandler above. One socket ⇒ one pinhole ⇒
# native is reachable exactly when HTTP is (advertise-when-reachable); a CGNAT node
# needs no second hole. bind_and_activate=False: we own the listening socket.
# The experimental native multiplexer is mounted only when the
# operator explicitly configured transport_endpoint. Ordinary nodes
# remain HTTP-only; compiling or importing the draft is not consent to
# expose it on the public listener.
native_enabled = bool(self._cfg.transport_endpoint)
server = ThreadingHTTPServer((host, port), _Handler, bind_and_activate=False)
native_server = IicpTcpServer(host=host, port=port, node_id=self._cfg.node_id, handler=handler)
native_server = (
IicpTcpServer(host=host, port=port, node_id=self._cfg.node_id, handler=handler) if native_enabled else None
)
# Bind to the address family implied by `host` — the CLI defaults host to
# "::" (IPv6), which a hardcoded AF_INET socket cannot bind (gaierror).
family = _listen_family(host, port)
Expand All @@ -1887,6 +1885,9 @@ def _json_response(self, status: int, body: bytes, cors: bool = True) -> None:
mux_stop = threading.Event()

async def _handle_native_conn(conn: socket.socket) -> None:
if native_server is None:
conn.close()
return
try:
conn.setblocking(False)
reader, writer = await asyncio.open_connection(sock=conn)
Expand All @@ -1911,7 +1912,7 @@ def _route_conn(conn: socket.socket, addr: Any) -> None:
pass
return
conn.settimeout(None)
if prefix == IICP_MAGIC:
if native_enabled and prefix == IICP_MAGIC:
asyncio.run_coroutine_threadsafe(_handle_native_conn(conn), loop)
else:
# ThreadingHTTPServer.process_request threads the request; _Handler reads the
Expand All @@ -1929,12 +1930,16 @@ def _accept_loop() -> None:
# Peek+route off the accept thread so a slow client can't block new connections.
threading.Thread(target=_route_conn, args=(conn, addr), daemon=True).start()

logger.info(
"IICP node %s listening on %s:%d (HTTP + native IICP, single port)",
self._cfg.node_id,
host,
port,
)
if native_enabled:
logger.warning(
"IICP node %s enabled experimental plaintext native TCP on %s:%d; "
"it is excluded from stable and production claims",
self._cfg.node_id,
host,
port,
)
else:
logger.info("IICP node %s listening on %s:%d (HTTP)", self._cfg.node_id, host, port)
self._runtime_health.mark_running()

bg_tasks: list[asyncio.Task] = []
Expand Down
30 changes: 25 additions & 5 deletions src/iicp_client/proxy/routing/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
- ADR-008 — directory score ordering is authoritative; the proxy preserves it
- spec/iicp-core.md §10 — retry/idempotency semantics for client implementations
"""

from __future__ import annotations

import logging
import os
from typing import Any
from uuid import UUID

Expand All @@ -29,6 +31,23 @@
logger = logging.getLogger(__name__)


def _experimental_native_enabled() -> bool:
"""Return whether directory native routes may enter this proxy path.

The stable proxy ignores ``transport_endpoint`` by default. Direct callers
can still construct ``NodeClient`` with an endpoint for bounded experiments.
"""
raw = os.environ.get("IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP")
if raw is None:
return False
value = raw.strip().lower()
if value in {"1", "true", "yes"}:
return True
if value not in {"0", "false", "no"}:
logger.warning("Ignoring invalid IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP value in proxy routing")
return False


class TaskRouter:
"""Dispatch a task to one node, respecting retry + circuit-breaker policy.

Expand Down Expand Up @@ -72,25 +91,26 @@ async def route(
"""
node_id = node["node_id"]
endpoint = node["endpoint"]
transport_endpoint = node.get("transport_endpoint") # spec v0.7.0 dual-endpoint fallback
transport_endpoint = node.get("transport_endpoint") if _experimental_native_enabled() else None

if not _is_ssrf_safe(endpoint):
logger.warning(
"Router: SSRF guard — skipping node %s with non-routable endpoint %s",
node_id[:8] if len(node_id) > 8 else node_id,
endpoint,
)
raise ValueError(
f"Node endpoint '{endpoint}' is not publicly routable (SSRF guard)"
)
raise ValueError(f"Node endpoint '{endpoint}' is not publicly routable (SSRF guard)")

self._circuit.check(node_id)

client = NodeClient(endpoint, self._token, transport_endpoint=transport_endpoint)

async def attempt() -> dict[str, Any]:
return await client.submit_task(
task_id, intent, payload, timeout_ms,
task_id,
intent,
payload,
timeout_ms,
cip_envelope=cip_envelope,
source_node_id=source_node_id,
)
Expand Down
9 changes: 9 additions & 0 deletions src/iicp_client/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,15 @@ def _supervisor_tunnel_environment() -> dict[str, str]:
result["IICP_CLOUDFLARED_PATH"] = binary
if normalized is not None:
result["IICP_TUNNEL"] = normalized
native = os.environ.get("IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP")
if native is not None:
value = native.strip().lower()
if value in {"1", "true", "yes"}:
result["IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP"] = "1"
elif value in {"0", "false", "no"}:
result["IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP"] = "0"
else:
raise ValueError("IICP_ENABLE_EXPERIMENTAL_NATIVE_TCP must be one of 1/true/yes or 0/false/no")
return result


Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/native-framing-v1.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"fixture_version": "1.0.0-draft",
"status": "implementation-backed-pre-ratification",
"purpose": "Cross-implementation native framing vectors for the current ordered-stream binding. They cover bounded frame decoding and the fail-closed stable task-session type boundary. Dispatch direction, TLS, lifecycle, dedicated experimental relay sessions, logical fragmentation and unsupported QUIC behavior remain outside this fixture.",
"purpose": "Cross-implementation native framing vectors for the current experimental ordered-stream binding. They cover bounded frame decoding and the fail-closed task-session type boundary. Passing these vectors does not admit native TCP to the coordinated stable or production baseline. Dispatch direction, TLS, lifecycle, dedicated experimental relay sessions, logical fragmentation and unsupported QUIC behavior remain outside this fixture.",
"frame": {
"framing_version": 1,
"header_bytes": 12,
Expand Down Expand Up @@ -71,7 +71,7 @@
"conflict": "0x0B/0x0C are CONTROL/ADVERTISE in the draft registry but RELAY_BIND/RELAY_ACK in maintained relay experiments; stable task sessions reject both bytes before proportional allocation.",
"relay_boundary": "Experimental relay sessions remain isolated on their dedicated transport and do not count as stable task-profile conformance.",
"extension_boundary": "0xF0-0xFE require negotiated extension state; the current stable task profile negotiates none and rejects them before proportional allocation.",
"production_security_disposition": "open_qualify_or_exclude",
"production_security_disposition": "excluded_from_stable_baseline",
"plaintext_scope": "development_only",
"stable_claim": "not_admitted"
},
Expand Down
Loading
Loading