2626import json
2727import logging
2828import os
29+ import socket
2930import subprocess
3031import sys
3132import urllib .parse
@@ -53,6 +54,26 @@ def _env(name: str, default: str | None = None) -> str | None:
5354 return os .environ .get (name , default )
5455
5556
57+ def _find_available_port (host : str , start : int , max_tries : int = 64 ) -> int :
58+ """Return the first bindable TCP port >= ``start`` on ``host``.
59+
60+ The official IICP port 9484 is the starting point; when running multiple
61+ nodes on one host (each model on its own port → its own pinhole) the second
62+ node auto-increments to 9485, the third to 9486, and so on. Probes by
63+ attempting a real bind so the chosen port is genuinely free before NAT
64+ detection opens a pinhole and the directory registration advertises it.
65+ """
66+ bind_host = host if host not in ("" , "0.0.0.0" ) else "0.0.0.0"
67+ for candidate in range (start , start + max_tries ):
68+ with socket .socket (socket .AF_INET , socket .SOCK_STREAM ) as probe :
69+ try :
70+ probe .bind ((bind_host , candidate ))
71+ return candidate
72+ except OSError :
73+ continue
74+ return start # exhausted — let serve() surface the real bind error
75+
76+
5677def _build_parser () -> argparse .ArgumentParser :
5778 p = argparse .ArgumentParser (
5879 prog = "iicp-node" ,
@@ -128,7 +149,7 @@ def _build_parser() -> argparse.ArgumentParser:
128149 serve .add_argument (
129150 "--port" ,
130151 type = int ,
131- default = int (_env ("IICP_PORT" , "8020 " ) or "8020 " ),
152+ default = int (_env ("IICP_PORT" , "9484 " ) or "9484 " ),
132153 help = "HTTP listen port. env: IICP_PORT" ,
133154 )
134155 serve .add_argument (
@@ -208,7 +229,7 @@ async def _serve(args: argparse.Namespace) -> int:
208229 args .node_id = args .node_id or saved .node_id
209230 if args .max_concurrent == 4 :
210231 args .max_concurrent = saved .max_concurrent
211- if args .port == 8020 :
232+ if args .port == 9484 :
212233 args .port = saved .port
213234 if args .host == "0.0.0.0" :
214235 args .host = saved .host
@@ -225,6 +246,21 @@ async def _serve(args: argparse.Namespace) -> int:
225246 )
226247 return 2
227248
249+ # Resolve the actual listen port before NAT detection: start at the
250+ # requested port (default 9484, the official IICP port) and auto-increment
251+ # to the next free port. This keeps one port per node (multiple models on
252+ # one node share it) while N nodes on one host each get a distinct port →
253+ # distinct pinhole. Skipped when the operator supplies an explicit
254+ # --public-endpoint (they own the port mapping in that case).
255+ if not args .public_endpoint :
256+ resolved_port = _find_available_port (args .host , args .port )
257+ if resolved_port != args .port :
258+ logger .info (
259+ "Port %d in use — auto-incremented to first free port %d." ,
260+ args .port , resolved_port ,
261+ )
262+ args .port = resolved_port
263+
228264 node_id = (args .node_id or str (uuid .uuid4 ()))[:36 ]
229265 public_endpoint = args .public_endpoint or f"http://localhost:{ args .port } "
230266
@@ -467,7 +503,7 @@ def _cmd_init(args: argparse.Namespace) -> int:
467503 intent = _prompt ("Intent URN" , "urn:iicp:intent:llm:chat:v1" )
468504 region = _prompt ("Region tag" , "eu-central" )
469505 directory_url = _prompt ("Directory URL" , "https://iicp.network/api" )
470- port_str = _prompt ("Local HTTP port" , "8020 " )
506+ port_str = _prompt ("Local HTTP port" , "9484 " )
471507 port = int (port_str )
472508 public_endpoint = _prompt (
473509 "Public endpoint URL (leave blank if you'll use --auto-detect-nat)" ,
0 commit comments