feat(service): support explicit wildcard network binds (#547)

* feat(service): bind network services to all interfaces

* fix(service): keep network listeners local by default

* fix(service): make remote access explicit
This commit is contained in:
jinliyl 2026-09-15 14:26:33 +08:00 • committed by GitHub
parent d67f1490f5
commit b9caae1e50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 202 additions and 52 deletions

View file

@ -5,7 +5,8 @@ description: Run ReMe through HTTP, SSE, MCP, the CLI, and ReMe Studio while res
# Services and Deployment
ReMe can run as a local HTTP service, a standalone MCP server, or a one-shot CLI Job. The default starts HTTP on `127.0.0.1:2333` and serves JSON, SSE, streamable HTTP MCP, and optional ReMe Studio from one process.
ReMe can run as a local HTTP service, a standalone MCP server, or a one-shot CLI Job. By default HTTP binds to
`127.0.0.1:2333`; one process serves JSON, SSE, streamable HTTP MCP, and optional ReMe Studio.
## HTTP API
@ -108,7 +109,9 @@ ReMe is local-first:
- Jobs may write, move, or delete files;
- the service layer has no general-purpose user authentication.
Do not expose the default service directly to the public internet. For remote access, place it on a controlled network or behind an authenticated TLS reverse proxy, apply access controls and request-size limits, and expose only necessary Jobs.
Remote access must be enabled explicitly with `reme start service.host=0.0.0.0`. Do not expose the service directly to
the public internet. Place it on a controlled network or behind an authenticated TLS reverse proxy, apply access
controls and request-size limits, and expose only necessary Jobs.
## OpenAPI

View file

@ -5,7 +5,8 @@ description: 使用 ReMe 的 HTTP、SSE、MCP 和 Studio 服务,并理解默
# 服务与部署
ReMe 可以作为本地 HTTP 服务、独立 MCP Server 或一次性 CLI Job 运行。默认模式是在 `127.0.0.1:2333` 启动 HTTP 服务,并在同一进程中提供 JSON API、SSE、MCP 与可选的 ReMe Studio。
ReMe 可以作为本地 HTTP 服务、独立 MCP Server 或一次性 CLI Job 运行。默认 HTTP 服务监听
`127.0.0.1:2333`;同一进程提供 JSON API、SSE、MCP 与可选的 ReMe Studio。
## HTTP 服务
@ -124,7 +125,8 @@ ReMe 默认定位为本地服务:
- Job 可进行文件写入、移动和删除;
- 当前服务层不提供通用用户认证。
不要直接把默认服务暴露到公网。需要远程访问时,在受控网络或带身份认证、TLS、访问控制和请求大小限制的反向代理后部署,并通过 `service.jobs` 只开放必要 Job。
远程访问必须通过 `reme start service.host=0.0.0.0` 显式启用。不要直接把服务暴露到公网。请在受控网络或带
身份认证、TLS、访问控制和请求大小限制的反向代理后部署,并通过 `service.jobs` 只开放必要 Job。
## OpenAPI

View file

@ -1,10 +1,17 @@
"""Base client abstraction."""
import json
import os
from abc import abstractmethod
from collections.abc import AsyncGenerator
from ..base_component import BaseComponent
from ...constants import (
REME_DEFAULT_HOST,
REME_DEFAULT_PORT,
REME_SERVICE_INFO,
normalize_connect_host,
)
from ...enumeration import ComponentEnum
@ -17,6 +24,20 @@ class BaseClient(BaseComponent):
super().__init__(**kwargs)
self.client = None
def _resolve_service_address(self, host: str | None, port: int | None) -> tuple[str, int]:
"""Resolve explicit, discovered, or default network coordinates."""
if not (host and port):
if service_info := os.environ.get(REME_SERVICE_INFO):
try:
data = json.loads(service_info)
host, port = data["host"], data["port"]
except Exception:
self.logger.warning(f"Invalid service info: {service_info}")
host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
else:
host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
return normalize_connect_host(host), port
async def _start(self) -> None:
"""Initialize the client."""

View file

@ -1,14 +1,12 @@
"""HTTP client for ReMe services."""
import json
import os
from collections.abc import AsyncGenerator
import httpx
from .base_client import BaseClient
from ..component_registry import R
from ...constants import REME_SERVICE_INFO, REME_DEFAULT_HOST, REME_DEFAULT_PORT
from ...enumeration import ChunkEnum
from ...schema import StreamChunk
@ -27,19 +25,7 @@ class HttpClient(BaseClient):
):
super().__init__(**kwargs)
# Resolve host/port: explicit args > env var > defaults
if not (host and port):
if service_info := os.environ.get(REME_SERVICE_INFO):
try:
data = json.loads(service_info)
host = data["host"]
port = data["port"]
except Exception:
self.logger.warning(f"Invalid service info: {service_info}")
host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
else:
host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
host, port = self._resolve_service_address(host, port)
self.base_url = f"http://{host}:{port}"
self.timeout = timeout
self.show_metadata = show_metadata

View file

@ -1,13 +1,10 @@
"""MCP client for ReMe services."""
import json
import os
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any
from .base_client import BaseClient
from ..component_registry import R
from ...constants import REME_SERVICE_INFO, REME_DEFAULT_HOST, REME_DEFAULT_PORT
if TYPE_CHECKING:
from fastmcp.client.client import CallToolResult
@ -51,19 +48,7 @@ class MCPClient(BaseClient):
raise ValueError(f"Unknown transport: {transport!r}, expected one of {sorted(_VALID_TRANSPORTS)}")
if isinstance(transport, str) and transport != "stdio":
if not (host and port):
if service_info := os.environ.get(REME_SERVICE_INFO):
try:
data = json.loads(service_info)
host = data["host"]
port = data["port"]
except Exception:
self.logger.warning(f"Invalid service info: {service_info}")
host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
else:
host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
self.host = host
self.port = port
self.host, self.port = self._resolve_service_address(host, port)
self.transport = transport
self.timeout = timeout

View file

@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
from ..base_component import BaseComponent
from ..job.base_job import BaseJob
from ...constants import REME_SERVICE_INFO
from ...constants import REME_SERVICE_INFO, normalize_connect_host
from ...enumeration import ComponentEnum
if TYPE_CHECKING:
@ -52,15 +52,16 @@ class BaseService(BaseComponent):
def _lifespan(self, app: "Application", host: str, port: int):
"""Build an async-context lifespan that brackets the server with app start/close.
Publishes the bound address via the REME_SERVICE_INFO environment variable so
in-process clients can discover where this service is listening.
Publishes a connectable address via the REME_SERVICE_INFO environment variable
so in-process clients can discover the service even when it binds a wildcard.
"""
@asynccontextmanager
async def lifespan(_):
await app.start()
try:
service_info = json.dumps({"host": host, "port": port})
advertised_host = normalize_connect_host(host)
service_info = json.dumps({"host": advertised_host, "port": port})
os.environ[REME_SERVICE_INFO] = service_info
self.logger.info(f"{self.name} started: {REME_SERVICE_INFO}={service_info}")
yield

View file

@ -2,10 +2,21 @@
REME_SERVICE_INFO = "REME_SERVICE_INFO"
# Loopback address used by services and clients unless a host is configured.
REME_DEFAULT_HOST = "127.0.0.1"
# Wildcard address accepted when a service is explicitly configured to listen
# on every IPv4 interface.
REME_WILDCARD_BIND_HOST = "0.0.0.0"
REME_DEFAULT_PORT = 2333
def normalize_connect_host(host: str) -> str:
"""Return a local destination for an address used only for binding."""
return REME_DEFAULT_HOST if host == REME_WILDCARD_BIND_HOST else host
# CRUD steps: file IO limits and truncation marker (shared across CRUD steps).
DEFAULT_MAX_BYTES = 50 * 1024
MAX_FILE_READ_BYTES = 200 * 1024 * 1024

View file

@ -10,7 +10,11 @@ from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from ..constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
from ..constants import (
REME_DEFAULT_HOST,
REME_DEFAULT_PORT,
normalize_connect_host,
)
if TYPE_CHECKING:
from ..components.service import BaseService
@ -78,11 +82,12 @@ def print_logo(app_config: "ApplicationConfig", runtime_service: "BaseService |
case "http":
host = getattr(runtime_service, "host", extra.get("host", REME_DEFAULT_HOST))
port = getattr(runtime_service, "port", extra.get("port", REME_DEFAULT_PORT))
info_table.add_row("🔗", "URL:", f"http://{host}:{port}")
display_host = normalize_connect_host(host)
info_table.add_row("🔗", "URL:", f"http://{display_host}:{port}")
mcp_enabled = getattr(runtime_service, "mcp_enabled", extra.get("mcp_enabled", True))
if mcp_enabled:
mcp_path = getattr(runtime_service, "mcp_path", extra.get("mcp_path", "/mcp"))
info_table.add_row("🚌", "MCP:", f"http://{host}:{port}{mcp_path}")
info_table.add_row("🚌", "MCP:", f"http://{display_host}:{port}{mcp_path}")
info_table.add_row("📚", "FastAPI:", Text(get_version("fastapi"), style="dim"))
if mcp_enabled:
info_table.add_row("📚", "FastMCP:", Text(get_version("fastmcp"), style="dim"))
@ -92,7 +97,8 @@ def print_logo(app_config: "ApplicationConfig", runtime_service: "BaseService |
if transport != "stdio":
host = getattr(runtime_service, "host", extra.get("host", REME_DEFAULT_HOST))
port = getattr(runtime_service, "port", extra.get("port", REME_DEFAULT_PORT))
url = f"http://{host}:{port}"
display_host = normalize_connect_host(host)
url = f"http://{display_host}:{port}"
if transport == "sse":
url += "/sse"
info_table.add_row("🔗", "URL:", url)

View file

@ -6,7 +6,11 @@ import sys
import psutil
from ..constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
from ..constants import (
REME_DEFAULT_HOST,
REME_DEFAULT_PORT,
normalize_connect_host,
)
async def find_reme(host: str, port: int) -> str:
@ -14,7 +18,7 @@ async def find_reme(host: str, port: int) -> str:
from ..components.client.http_client import HttpClient
try:
async with HttpClient(host=host, port=port, timeout=2.0) as client:
async with HttpClient(host=normalize_connect_host(host), port=port, timeout=2.0) as client:
async for _ in client(action="health_check"):
break
return "reme"
@ -59,7 +63,8 @@ def _scan_reme_procs() -> list[tuple[int, str, int]]:
if "start" not in cmdline or not any("reme" in tok for tok in cmdline):
continue
host, port = REME_DEFAULT_HOST, REME_DEFAULT_PORT
for t in cmdline:
for raw_arg in cmdline:
t = raw_arg.lstrip("-")
if t.startswith("service.host="):
host = t.split("=", 1)[1]
elif t.startswith("service.port=") and t.split("=", 1)[1].isdigit():
@ -124,7 +129,7 @@ async def locate_reme() -> tuple[str, int, int | None] | None:
return REME_DEFAULT_HOST, REME_DEFAULT_PORT, _pid_on_port(REME_DEFAULT_PORT)
for pid, host, port in _scan_reme_procs():
if await find_reme(host, port) == "reme":
return host, port, pid
return normalize_connect_host(host), port, pid
return None
@ -135,7 +140,7 @@ def precheck_start(svc_config: dict | None) -> bool:
port = int(port)
status = asyncio.run(find_reme(host, port))
if status == "reme":
print(f"reme already running at {host}:{port}")
print(f"reme already running at {normalize_connect_host(host)}:{port}")
return False
if status == "occupied":
print(

View file

@ -298,6 +298,20 @@ needed:
NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:8000 npm run dev
```
For development from another machine on a controlled network, bind both services explicitly and configure the browser
to use the ReMe host's reachable address (replace `192.168.1.10` as appropriate):
```bash
# ReMe repository root, on the host running ReMe
reme start service.host=0.0.0.0
# reme_studio/, on the same host
NEXT_PUBLIC_REME_API_URL=http://192.168.1.10:2333 npm run dev:remote
```
The ReMe service has no general-purpose authentication. Do not use the remote development command on an untrusted
network or expose either endpoint directly to the public internet.
## Build the ReMe-hosted static frontend
ReMe can serve Studio from the same FastAPI process as its HTTP API. Build the static variant and restart ReMe:
@ -316,6 +330,8 @@ Open <http://127.0.0.1:2333>. The static build uses same-origin requests by defa
VITE_REME_API_URL=http://127.0.0.1:2333 npm run dev:static
```
Use `dev:static:remote` with a reachable `VITE_REME_API_URL` for explicit remote static development.
`npm run build` remains the vinext/Sites deployment build. `npm run build:static` creates `dist-static/` exclusively for
FastAPI and Python/npm package distribution. Change frontend source rather than committing generated distribution files.

View file

@ -263,6 +263,19 @@ npm run dev
NEXT_PUBLIC_REME_API_URL=http://127.0.0.1:8000 npm run dev
```
需要从受控网络中的另一台机器进行开发时,应显式开放两个服务,并让浏览器连接 ReMe 主机的可达地址(请按
实际情况替换 `192.168.1.10`):
```bash
# 在运行 ReMe 的主机上,从仓库根目录执行
reme start service.host=0.0.0.0
# 在同一主机的 reme_studio/ 目录执行
NEXT_PUBLIC_REME_API_URL=http://192.168.1.10:2333 npm run dev:remote
```
ReMe 服务当前不提供通用身份认证。请勿在不可信网络中使用远程开发命令,也不要把任一端点直接暴露到公网。
## 构建由 ReMe 托管的静态前端
ReMe 可以使用提供 HTTP API 的同一个 FastAPI 进程托管 Studio。构建静态版本并重启 ReMe:
@ -281,6 +294,8 @@ reme start
VITE_REME_API_URL=http://127.0.0.1:2333 npm run dev:static
```
需要显式进行远程静态开发时,请使用 `dev:static:remote`,并将 `VITE_REME_API_URL` 设置为可达地址。
`npm run build` 仍用于 vinext/Sites 部署构建;`npm run build:static` 仅为 FastAPI 以及 Python/npm 包分发生成 `dist-static/`。应修改前端源文件,而不是提交生成的分发文件。
## 常见问题

View file

@ -22,7 +22,9 @@
},
"scripts": {
"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev --force",
"dev:remote": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev --force --hostname 0.0.0.0",
"dev:static": "vite --config vite.static.config.ts",
"dev:static:remote": "vite --config vite.static.config.ts --host 0.0.0.0",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"build:static": "vite build --config vite.static.config.ts",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",

View file

@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("Studio development servers require an explicit remote command", async () => {
const packageJson = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8"),
);
const { scripts } = packageJson;
assert.doesNotMatch(scripts.dev, /0\.0\.0\.0/);
assert.doesNotMatch(scripts["dev:static"], /0\.0\.0\.0/);
assert.match(scripts["dev:remote"], /--hostname 0\.0\.0\.0/);
assert.match(scripts["dev:static:remote"], /--host 0\.0\.0\.0/);
});

View file

@ -3,6 +3,21 @@
# pylint: disable=protected-access
from reme.components.client.http_client import HttpClient
from reme.constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
def test_default_client_uses_loopback_address():
"""Clients connect to loopback when no service address is configured."""
client = HttpClient()
assert client.base_url == f"http://{REME_DEFAULT_HOST}:{REME_DEFAULT_PORT}"
def test_client_converts_wildcard_bind_address_to_loopback():
"""A service's wildcard bind address is not advertised as a destination."""
client = HttpClient(host="0.0.0.0", port=8123)
assert client.base_url == "http://127.0.0.1:8123"
def test_format_for_display_hides_metadata_by_default():

View file

@ -29,8 +29,9 @@ def test_logo_uses_runtime_http_address(monkeypatch) -> None:
output = _render_logo(monkeypatch, config, runtime_service)
assert "http://0.0.0.0:8123" in output
assert "http://0.0.0.0:8123/mcp" in output
assert "http://127.0.0.1:8123" in output
assert "http://127.0.0.1:8123/mcp" in output
assert "http://0.0.0.0:8123" not in output
def test_logo_fallback_matches_service_defaults(monkeypatch) -> None:
@ -62,7 +63,8 @@ def test_logo_uses_runtime_mcp_transport_and_address(monkeypatch) -> None:
output = _render_logo(monkeypatch, config, runtime_service)
assert "Transport: sse" in output
assert "http://0.0.0.0:8123/sse" in output
assert "http://127.0.0.1:8123/sse" in output
assert "http://0.0.0.0:8123/sse" not in output
def test_logo_mcp_fallback_matches_service_defaults(monkeypatch) -> None:

View file

@ -0,0 +1,20 @@
"""MCP client address defaults."""
from reme.components.client.mcp_client import MCPClient
from reme.constants import REME_DEFAULT_HOST, REME_DEFAULT_PORT
def test_default_mcp_client_uses_loopback_address():
"""The network MCP client uses a connectable loopback destination."""
client = MCPClient()
assert client.host == REME_DEFAULT_HOST
assert client.port == REME_DEFAULT_PORT
def test_mcp_client_converts_wildcard_bind_address_to_loopback():
"""A wildcard service address is normalized before transport construction."""
client = MCPClient(host="0.0.0.0", port=8123)
assert client.host == "127.0.0.1"
assert client.port == 8123

View file

@ -1,13 +1,16 @@
"""Tests for service job registration behavior."""
import asyncio
import json
import os
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from reme.components.job import BaseJob, StreamJob
from reme.components.service import MCPService
from reme.components.service import HttpService, MCPService
from reme.constants import REME_SERVICE_INFO
from reme.schema import Response
@ -190,3 +193,28 @@ def test_service_lifespan_closes_app_after_error():
assert events == ["start", "close"]
asyncio.run(run())
def test_network_services_bind_loopback_by_default():
"""HTTP and network MCP services stay local unless remote access is explicit."""
assert HttpService().host == "127.0.0.1"
assert MCPService().host == "127.0.0.1"
def test_network_services_accept_explicit_wildcard_bind():
"""Remote access remains available through explicit service configuration."""
assert HttpService(host="0.0.0.0").host == "0.0.0.0"
assert MCPService(host="0.0.0.0").host == "0.0.0.0"
def test_service_lifespan_advertises_loopback_for_wildcard_bind(monkeypatch):
"""In-process clients receive a connectable address, not the wildcard bind address."""
monkeypatch.delenv(REME_SERVICE_INFO, raising=False)
async def run():
app = _dummy_app()
lifespan = HttpService()._lifespan(app, "0.0.0.0", 8123) # pylint: disable=protected-access
async with lifespan(None):
assert json.loads(os.environ[REME_SERVICE_INFO]) == {"host": "127.0.0.1", "port": 8123}
asyncio.run(run())

View file

@ -18,6 +18,7 @@ from types import SimpleNamespace
import psutil
from reme.constants import normalize_connect_host
from reme.utils import service_utils as su
# ----------------------------------------------------------------------
@ -125,6 +126,17 @@ def test_scan_reme_procs_parses_host_and_port(monkeypatch):
assert su._scan_reme_procs() == [(123, "0.0.0.0", 8123)]
def test_scan_reme_procs_accepts_prefixed_cli_arguments(monkeypatch):
procs = [
_FakeProc(
123,
cmdline=["reme", "start", "--service.host=0.0.0.0", "-service.port=8123"],
),
]
_patch_iter(monkeypatch, procs)
assert su._scan_reme_procs() == [(123, "0.0.0.0", 8123)]
def test_scan_reme_procs_defaults_when_args_absent(monkeypatch):
procs = [_FakeProc(7, cmdline=["reme", "start"])]
_patch_iter(monkeypatch, procs)
@ -158,6 +170,11 @@ def test_scan_reme_procs_skips_access_denied(monkeypatch):
assert su._scan_reme_procs() == [(5, su.REME_DEFAULT_HOST, su.REME_DEFAULT_PORT)]
def test_connect_host_converts_wildcard_bind_address():
assert normalize_connect_host("0.0.0.0") == "127.0.0.1"
assert normalize_connect_host("192.0.2.10") == "192.0.2.10"
def test_running_app_config_preserves_plugins(monkeypatch):
"""Process replay exposes the full config while the compatibility helper returns service only."""
monkeypatch.setattr(su, "_reme_start_argv", lambda: [["config=example"]])