diff --git a/.env.example b/.env.example index fd1f9c5..2b5917b 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ NANO_API_BASE= # Must be reachable from app-instance containers. NANO_AUTHZ_URL=http://nano-authz-service:19090 +NANO_OUTLOOK_MCP_URL= +NANO_OUTLOOK_MCP_SERVER_ID=outlook_mcp # Must be reachable from auth-portal and authz-service containers. NANO_DEPLOY_URL=http://nano-deploy-control:8090 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7b421f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Runtime data generated by local Docker deployment +authz-service/runtime/data/ +app-instance/runtime/instances/ +app-instance/runtime/registry/ +router-proxy/runtime/conf.d/ + +# Local build / cache artifacts +**/__pycache__/ +**/.pytest_cache/ +**/node_modules/ +**/.next/ +*.log diff --git a/README.md b/README.md index 39c69fe..b610e22 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,12 @@ Browser - 可空,自定义 provider base URL - `NANO_AUTHZ_URL` - 这个值必须是 `app-instance` 容器能访问到的 AuthZ 地址 +- `NANO_OUTLOOK_MCP_URL` + - 可空。 + - 如果配置了,所有新创建的 `app-instance` 都会默认带一个 Outlook MCP HTTP 工具配置。 +- `NANO_OUTLOOK_MCP_SERVER_ID` + - Outlook MCP 默认 server id。 + - 推荐固定成 `outlook_mcp`。 - `NANO_DEPLOY_URL` - `auth-portal` 和 `authz-service` 在容器网络里访问 deploy-control 的地址 @@ -114,6 +120,8 @@ export NANO_API_KEY='sk-xxxxxxxx' export NANO_API_BASE='' export NANO_AUTHZ_URL='http://nano-authz-service:19090' +export NANO_OUTLOOK_MCP_URL='' +export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp' export NANO_DEPLOY_URL='http://nano-deploy-control:8090' ``` @@ -236,8 +244,10 @@ docker run -d \ --network "$NANO_NET" \ -p 8090:8090 \ -v /var/run/docker.sock:/var/run/docker.sock \ - -v "$PROJECT_ROOT/app-instance:/app-instance" \ - -v "$PROJECT_ROOT/router-proxy:/router-proxy" \ + -v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \ + -v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \ + -e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \ + -e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \ -e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \ -e APP_INSTANCE_IMAGE="nano/app-instance:latest" \ -e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \ @@ -246,6 +256,8 @@ docker run -d \ -e APP_INSTANCE_API_KEY="$NANO_API_KEY" \ -e APP_INSTANCE_API_BASE="$NANO_API_BASE" \ -e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \ + -e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \ + -e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \ -e DEPLOY_PUBLIC_SCHEME="http" \ -e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \ -e DEPLOY_PUBLIC_PORT="8088" \ @@ -253,6 +265,14 @@ docker run -d \ nano/deploy-control:latest ``` +这里不要把宿主机目录挂到容器内的另一个短路径,比如 `/app-instance`。 + +原因是 `deploy-control` 会通过挂载进来的 Docker socket 再去创建 `app-instance` 容器;这时传给 Docker 的 bind mount 源路径必须是宿主机真实路径。如果你把宿主机目录映射成容器内短路径,`create-instance.sh` 生成的挂载源就会变成错误路径,最终表现为: + +- 注册接口超时 +- `app-instance` 容器反复重启 +- 日志里出现 `Missing nanobot config: /root/.nanobot/config.json` + 当前版本里,新实例的默认大模型配置就是从这里分发的: - `APP_INSTANCE_PROVIDER` diff --git a/app-instance/backend/nanobot/web/outlook.py b/app-instance/backend/nanobot/web/outlook.py index 689eaf2..5d75b69 100644 --- a/app-instance/backend/nanobot/web/outlook.py +++ b/app-instance/backend/nanobot/web/outlook.py @@ -21,7 +21,10 @@ from loguru import logger from nanobot.authz.client import AuthzClient from nanobot.config.schema import Config, MCPServerConfig -OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook") +OUTLOOK_SERVER_ID = os.getenv("NANOBOT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp") +OUTLOOK_OVERVIEW_MESSAGE_LIMIT = 8 +OUTLOOK_OVERVIEW_EVENT_LIMIT = 20 +OUTLOOK_MAX_PAGE_SIZE = 100 class OutlookIntegrationError(RuntimeError): @@ -125,6 +128,38 @@ def _default_outlook_permissions() -> dict[str, Any]: } +def _normalize_page_args(*, top: int, skip: int) -> tuple[int, int]: + safe_top = max(1, min(int(top), OUTLOOK_MAX_PAGE_SIZE)) + safe_skip = max(0, int(skip)) + return safe_top, safe_skip + + +def _normalize_page_payload(payload: dict[str, Any], *, top: int, skip: int) -> dict[str, Any]: + items = payload.get("value", []) if isinstance(payload, dict) else [] + returned = len(items) if isinstance(items, list) else 0 + page = payload.get("page") if isinstance(payload, dict) else None + if isinstance(page, dict): + normalized = dict(payload) + normalized["page"] = { + "top": int(page.get("top", top)), + "skip": int(page.get("skip", skip)), + "returned": int(page.get("returned", returned)), + "has_more": bool(page.get("has_more", False)), + "next_skip": page.get("next_skip"), + } + return normalized + return { + **payload, + "page": { + "top": top, + "skip": skip, + "returned": returned, + "has_more": returned >= top, + "next_skip": skip + returned if returned >= top else None, + }, + } + + async def ensure_outlook_authz_permissions(config: Config) -> None: backend_id = _require_backend_identity(config) client = _authz_client(config) @@ -708,7 +743,7 @@ async def get_overview(config: Config) -> dict[str, Any]: inbox = await _call_outlook_mcp_tool( config, "mail_list_messages", - {"folder": "inbox", "top": 8}, + {"folder": "inbox", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, scopes=["list_tools", "tool:mail_list_messages"], ) except Exception as exc: # noqa: BLE001 @@ -718,7 +753,7 @@ async def get_overview(config: Config) -> dict[str, Any]: sent = await _call_outlook_mcp_tool( config, "mail_list_messages", - {"folder": "sentitems", "top": 8}, + {"folder": "sentitems", "top": OUTLOOK_OVERVIEW_MESSAGE_LIMIT, "skip": 0}, scopes=["list_tools", "tool:mail_list_messages"], ) except Exception as exc: # noqa: BLE001 @@ -731,7 +766,8 @@ async def get_overview(config: Config) -> dict[str, Any]: { "start_time": start_of_day.isoformat(), "end_time": end_of_day.isoformat(), - "top": 20, + "top": OUTLOOK_OVERVIEW_EVENT_LIMIT, + "skip": 0, }, scopes=["list_tools", "tool:calendar_list_events"], ) @@ -764,13 +800,21 @@ async def get_overview(config: Config) -> dict[str, Any]: warnings: list[str] = [] try: - inbox = await provider.list_messages(folder="inbox", top=8) + inbox = await provider.list_messages( + folder="inbox", + top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT, + skip=0, + ) except Exception as exc: # noqa: BLE001 inbox = {"value": []} warnings.append(f"inbox unavailable: {exc}") try: - sent = await provider.list_messages(folder="sentitems", top=8) + sent = await provider.list_messages( + folder="sentitems", + top=OUTLOOK_OVERVIEW_MESSAGE_LIMIT, + skip=0, + ) except Exception as exc: # noqa: BLE001 sent = {"value": []} warnings.append(f"sent items unavailable: {exc}") @@ -779,7 +823,8 @@ async def get_overview(config: Config) -> dict[str, Any]: calendar = await provider.list_events( start_time=start_of_day.isoformat(), end_time=end_of_day.isoformat(), - top=20, + top=OUTLOOK_OVERVIEW_EVENT_LIMIT, + skip=0, ) except Exception as exc: # noqa: BLE001 calendar = {"value": []} @@ -825,6 +870,92 @@ async def get_message_detail( return await provider.get_message(message_id=message_id, changekey=changekey) +async def list_messages( + config: Config, + *, + folder: str, + top: int, + skip: int = 0, + unread_only: bool = False, +) -> dict[str, Any]: + safe_top, safe_skip = _normalize_page_args(top=top, skip=skip) + + if _use_authz_mode(config): + payload = await _call_outlook_mcp_tool( + config, + "mail_list_messages", + { + "folder": folder, + "top": safe_top, + "skip": safe_skip, + "unread_only": unread_only, + }, + scopes=["list_tools", "tool:mail_list_messages"], + ) + return { + "folder": folder, + "unread_only": unread_only, + **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), + } + + input_data = _saved_connection_input(config.workspace_path) + provider, _normalized, _mods = _build_provider(input_data) + payload = await provider.list_messages( + folder=folder, + top=safe_top, + skip=safe_skip, + unread_only=unread_only, + ) + return { + "folder": folder, + "unread_only": unread_only, + **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), + } + + +async def list_events( + config: Config, + *, + start_time: str, + end_time: str, + top: int, + skip: int = 0, +) -> dict[str, Any]: + safe_top, safe_skip = _normalize_page_args(top=top, skip=skip) + + if _use_authz_mode(config): + payload = await _call_outlook_mcp_tool( + config, + "calendar_list_events", + { + "start_time": start_time, + "end_time": end_time, + "top": safe_top, + "skip": safe_skip, + }, + scopes=["list_tools", "tool:calendar_list_events"], + ) + return { + "start_time": start_time, + "end_time": end_time, + **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), + } + + input_data = _saved_connection_input(config.workspace_path) + provider, _normalized, _mods = _build_provider(input_data) + payload = await provider.list_events( + start_time=start_time, + end_time=end_time, + top=safe_top, + skip=safe_skip, + ) + return { + "start_time": start_time, + "end_time": end_time, + **_normalize_page_payload(payload, top=safe_top, skip=safe_skip), + } + + def is_outlook_mcp_registered(config: Config) -> bool: return OUTLOOK_SERVER_ID in config.tools.mcp_servers diff --git a/app-instance/backend/nanobot/web/server.py b/app-instance/backend/nanobot/web/server.py index 8a97d53..226b7e9 100644 --- a/app-instance/backend/nanobot/web/server.py +++ b/app-instance/backend/nanobot/web/server.py @@ -2312,6 +2312,11 @@ def _register_routes(app: FastAPI) -> None: server_id = req.id.strip() if not server_id: raise HTTPException(status_code=400, detail="Server id is required") + auth_mode = (req.auth_mode or "none").strip().lower() or "none" + auth_audience = (req.auth_audience or "").strip() + auth_scopes = [str(item).strip() for item in list(req.auth_scopes or []) if str(item).strip()] + if auth_mode == "oauth_backend_token" and not auth_audience: + auth_audience = f"mcp:{server_id}" config.tools.mcp_servers[server_id] = MCPServerConfig( command=req.command, @@ -2319,9 +2324,9 @@ def _register_routes(app: FastAPI) -> None: env=req.env, url=req.url, headers=req.headers, - auth_mode=req.auth_mode, - auth_audience=req.auth_audience, - auth_scopes=req.auth_scopes, + auth_mode=auth_mode, + auth_audience=auth_audience, + auth_scopes=auth_scopes, tool_timeout=req.tool_timeout, sensitive=req.sensitive, ) @@ -2460,6 +2465,56 @@ def _register_routes(app: FastAPI) -> None: except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail=str(exc)) from exc + @app.get("/api/integrations/outlook/messages") + async def get_outlook_messages( + folder: str = "inbox", + top: int = 20, + skip: int = 0, + unread_only: bool = False, + ): + from nanobot.web.outlook import OutlookIntegrationError, list_messages + + config: Config = app.state.config + if not folder.strip(): + raise HTTPException(status_code=400, detail="folder is required") + try: + return await list_messages( + config, + folder=folder.strip(), + top=top, + skip=skip, + unread_only=unread_only, + ) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @app.get("/api/integrations/outlook/events") + async def get_outlook_events( + start_time: str, + end_time: str, + top: int = 20, + skip: int = 0, + ): + from nanobot.web.outlook import OutlookIntegrationError, list_events + + config: Config = app.state.config + if not start_time.strip() or not end_time.strip(): + raise HTTPException(status_code=400, detail="start_time and end_time are required") + try: + return await list_events( + config, + start_time=start_time.strip(), + end_time=end_time.strip(), + top=top, + skip=skip, + ) + except OutlookIntegrationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + @app.get("/api/integrations/outlook/message-detail") async def get_outlook_message_detail(message_id: str, changekey: str | None = None): from nanobot.web.outlook import OutlookIntegrationError, get_message_detail diff --git a/app-instance/create-instance.sh b/app-instance/create-instance.sh index 7e2b96b..723d185 100755 --- a/app-instance/create-instance.sh +++ b/app-instance/create-instance.sh @@ -16,6 +16,7 @@ HOST_PORT="" PUBLIC_URL="" AUTHZ_BASE_URL="" AUTHZ_OUTLOOK_MCP_URL="" +OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}" BACKEND_ID="" CLIENT_ID="" CLIENT_SECRET="" @@ -60,6 +61,8 @@ Optional: --authz-base-url AuthZ service base URL. --authz-outlook-mcp-url Managed Outlook MCP URL for AuthZ mode. + --outlook-mcp-server-id + Default Outlook MCP server id. Default: outlook_mcp --backend-id Pre-assigned backend id. --client-id Pre-assigned AuthZ client id. --client-secret Pre-assigned AuthZ client secret. @@ -133,6 +136,7 @@ render_config_json() { API_BASE="$API_BASE" \ AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \ AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \ + OUTLOOK_MCP_SERVER_ID="$OUTLOOK_MCP_SERVER_ID" \ BACKEND_ID="$BACKEND_ID" \ CLIENT_ID="$CLIENT_ID" \ CLIENT_SECRET="$CLIENT_SECRET" \ @@ -145,12 +149,48 @@ from pathlib import Path target = Path(os.environ["TARGET_PATH"]) provider = os.environ["PROVIDER"] +outlook_mcp_url = os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip() +outlook_server_id = os.environ["OUTLOOK_MCP_SERVER_ID"].strip() or "outlook_mcp" provider_cfg = {"apiKey": os.environ["API_KEY"]} api_base = os.environ["API_BASE"].strip() if api_base: provider_cfg["apiBase"] = api_base +outlook_tool_names = [ + "auth_status", + "mail_list_folders", + "mail_list_messages", + "mail_search_messages", + "mail_get_message", + "mail_send_email", + "mail_reply_to_message", + "mail_forward_message", + "mail_move_message", + "mail_delta_sync", + "calendar_list_events", + "calendar_create_event", + "calendar_update_event", + "calendar_get_schedule", + "calendar_find_meeting_times", + "calendar_delta_sync", +] + +default_mcp_servers = {} +if outlook_mcp_url: + default_mcp_servers[outlook_server_id] = { + "command": "", + "args": [], + "env": {}, + "url": outlook_mcp_url, + "headers": {}, + "authMode": "oauth_backend_token", + "authAudience": f"mcp:{outlook_server_id}", + "authScopes": ["list_tools", *[f"tool:{name}" for name in outlook_tool_names]], + "toolTimeout": 60, + "sensitive": True, + } + data = { "agents": { "defaults": { @@ -163,12 +203,13 @@ data = { }, "tools": { "restrictToWorkspace": True, + "mcpServers": default_mcp_servers, }, "authz": { "enabled": bool(os.environ["AUTHZ_BASE_URL"].strip()), "baseUrl": os.environ["AUTHZ_BASE_URL"].strip() or "http://127.0.0.1:19090", "requestTimeoutSeconds": 10, - "outlookMcpUrl": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(), + "outlookMcpUrl": outlook_mcp_url, }, "backend_identity": { "backendId": os.environ["BACKEND_ID"].strip(), @@ -281,6 +322,10 @@ while [[ $# -gt 0 ]]; do AUTHZ_OUTLOOK_MCP_URL="${2:-}" shift 2 ;; + --outlook-mcp-server-id) + OUTLOOK_MCP_SERVER_ID="${2:-}" + shift 2 + ;; --backend-id) BACKEND_ID="${2:-}" shift 2 @@ -448,6 +493,7 @@ RUN_ARGS=( -e "APP_PUBLIC_PORT=8080" -e "APP_FRONTEND_PORT=3000" -e "APP_BACKEND_PORT=18080" + -e "NANOBOT_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}" --label "nano.instance.id=${INSTANCE_ID}" --label "nano.instance.slug=${INSTANCE_SLUG}" --label "nano.instance.public_url=${PUBLIC_URL}" diff --git a/app-instance/frontend/app/(app)/outlook/page.tsx b/app-instance/frontend/app/(app)/outlook/page.tsx index fa64e89..1b6a1dd 100644 --- a/app-instance/frontend/app/(app)/outlook/page.tsx +++ b/app-instance/frontend/app/(app)/outlook/page.tsx @@ -19,7 +19,9 @@ import { import { connectOutlook, disconnectOutlook, + getOutlookEvents, getOutlookMessageDetail, + getOutlookMessages, getOutlookOverview, getOutlookStatus, testOutlookConnection, @@ -27,9 +29,12 @@ import { import type { OutlookConnectionPayload, OutlookConnectionTestResult, + OutlookEventListResponse, OutlookEventSummary, OutlookMessageDetail, + OutlookMessageListResponse, OutlookMessageSummary, + OutlookPageInfo, OutlookStatus, } from '@/types'; import { Badge } from '@/components/ui/badge'; @@ -52,6 +57,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; type OutlookFormState = OutlookConnectionPayload; type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings'; +type OutlookMailboxView = 'inbox' | 'sent'; + +const MAILBOX_PAGE_SIZE = 20; +const CALENDAR_PAGE_SIZE = 100; const EMPTY_FORM: OutlookFormState = { email: '', @@ -124,6 +133,18 @@ function formatTime(value?: string | null): string { }).format(date); } +function buildCalendarRange(anchorKey: string): { startTime: string; endTime: string } { + const anchor = new Date(`${anchorKey}T00:00:00`); + const start = Number.isNaN(anchor.getTime()) ? new Date() : anchor; + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 7); + return { + startTime: start.toISOString(), + endTime: end.toISOString(), + }; +} + function mailboxLabel( mailbox?: | { @@ -333,6 +354,15 @@ export default function OutlookPage() { const [messageLoading, setMessageLoading] = useState(false); const [selectedEvent, setSelectedEvent] = useState(null); const [activeView, setActiveView] = useState('settings'); + const [inboxPage, setInboxPage] = useState(null); + const [sentPage, setSentPage] = useState(null); + const [calendarPage, setCalendarPage] = useState(null); + const [calendarAnchorKey, setCalendarAnchorKey] = useState(toLocalDateKey(new Date())); + const [mailboxLoading, setMailboxLoading] = useState>({ + inbox: false, + sent: false, + }); + const [calendarLoading, setCalendarLoading] = useState(false); const applyStatus = useCallback((nextStatus: OutlookStatus, forceFormSync = false) => { setStatus(nextStatus); @@ -358,6 +388,44 @@ export default function OutlookPage() { } }, []); + const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => { + setMailboxLoading((current) => ({ ...current, [view]: true })); + try { + const nextPage = await getOutlookMessages(view === 'inbox' ? 'inbox' : 'sentitems', { + top: MAILBOX_PAGE_SIZE, + skip, + }); + if (view === 'inbox') { + setInboxPage(nextPage); + } else { + setSentPage(nextPage); + } + setError(null); + } catch (err: any) { + setError(err.message || `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`); + } finally { + setMailboxLoading((current) => ({ ...current, [view]: false })); + } + }, []); + + const loadCalendarPage = useCallback(async (anchorKey: string) => { + setCalendarLoading(true); + try { + const range = buildCalendarRange(anchorKey); + const nextPage = await getOutlookEvents({ + ...range, + top: CALENDAR_PAGE_SIZE, + skip: 0, + }); + setCalendarPage(nextPage); + setError(null); + } catch (err: any) { + setError(err.message || '加载日程失败'); + } finally { + setCalendarLoading(false); + } + }, []); + const loadStatus = useCallback(async ( background = false, options?: { @@ -465,14 +533,14 @@ export default function OutlookPage() { label: '收件箱', hint: '最近接收邮件', icon: Inbox, - count: overviewPending ? null : inboxCount, + count: null, }, { id: 'sent' as const, label: '发件箱', hint: '最近发送记录', icon: Send, - count: overviewPending ? null : sentCount, + count: null, }, { id: 'calendar' as const, @@ -497,6 +565,33 @@ export default function OutlookPage() { } }, [activeView, availableViews]); + useEffect(() => { + if (!isConfigured) { + return; + } + if (activeView === 'inbox' && !inboxPage && !mailboxLoading.inbox) { + void loadMailboxPage('inbox', 0); + } + if (activeView === 'sent' && !sentPage && !mailboxLoading.sent) { + void loadMailboxPage('sent', 0); + } + if (activeView === 'calendar' && !calendarPage && !calendarLoading) { + void loadCalendarPage(calendarAnchorKey); + } + }, [ + activeView, + calendarAnchorKey, + calendarLoading, + calendarPage, + inboxPage, + isConfigured, + loadCalendarPage, + loadMailboxPage, + mailboxLoading.inbox, + mailboxLoading.sent, + sentPage, + ]); + const updateField = (key: K, value: OutlookFormState[K]) => { setFormDirty(true); setForm((current) => ({ ...current, [key]: value })); @@ -524,6 +619,10 @@ export default function OutlookPage() { setForm((current) => ({ ...current, password: '' })); setFormDirty(false); setTestResult(null); + setInboxPage(null); + setSentPage(null); + setCalendarPage(null); + setCalendarAnchorKey(toLocalDateKey(new Date())); await loadStatus(true, { forceFormSync: true }); setActiveView('inbox'); } catch (err: any) { @@ -542,6 +641,10 @@ export default function OutlookPage() { setTestResult(null); setSelectedMessageRef(null); setSelectedEvent(null); + setInboxPage(null); + setSentPage(null); + setCalendarPage(null); + setCalendarAnchorKey(toLocalDateKey(new Date())); setActiveView('settings'); setFormDirty(false); await loadStatus(true, { forceFormSync: true }); @@ -554,6 +657,13 @@ export default function OutlookPage() { const refreshOverview = async () => { await loadStatus(true, { preserveOverview: true }); + if (activeView === 'inbox') { + await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0); + } else if (activeView === 'sent') { + await loadMailboxPage('sent', sentPage?.page.skip ?? 0); + } else if (activeView === 'calendar') { + await loadCalendarPage(calendarAnchorKey); + } }; return ( @@ -601,7 +711,7 @@ export default function OutlookPage() { ) : null} - @@ -669,10 +779,20 @@ export default function OutlookPage() { } - items={overview?.recentInbox || []} - loading={overviewPending} + items={inboxPage?.value || []} + page={inboxPage?.page || null} + loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)} emptyLabel="还没有读取到收件箱邮件" onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)} + onRefresh={() => void loadMailboxPage('inbox', inboxPage?.page.skip ?? 0)} + refreshing={mailboxLoading.inbox} + onPreviousPage={() => void loadMailboxPage('inbox', Math.max(0, (inboxPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))} + onNextPage={() => { + const nextSkip = inboxPage?.page.next_skip; + if (typeof nextSkip === 'number') { + void loadMailboxPage('inbox', nextSkip); + } + }} /> @@ -680,21 +800,48 @@ export default function OutlookPage() { } - items={overview?.recentSent || []} - loading={overviewPending} + items={sentPage?.value || []} + page={sentPage?.page || null} + loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)} emptyLabel="还没有读取到已发送邮件" onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)} + onRefresh={() => void loadMailboxPage('sent', sentPage?.page.skip ?? 0)} + refreshing={mailboxLoading.sent} + onPreviousPage={() => void loadMailboxPage('sent', Math.max(0, (sentPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))} + onNextPage={() => { + const nextSkip = sentPage?.page.next_skip; + if (typeof nextSkip === 'number') { + void loadMailboxPage('sent', nextSkip); + } + }} /> setSelectedEvent(item)} - onRefresh={refreshOverview} - refreshing={refreshing} + onRefresh={() => void loadCalendarPage(calendarAnchorKey)} + refreshing={calendarLoading} + onPreviousWeek={() => { + const next = new Date(`${calendarAnchorKey}T00:00:00`); + next.setDate(next.getDate() - 7); + setCalendarAnchorKey(toLocalDateKey(next)); + setCalendarPage(null); + }} + onNextWeek={() => { + const next = new Date(`${calendarAnchorKey}T00:00:00`); + next.setDate(next.getDate() + 7); + setCalendarAnchorKey(toLocalDateKey(next)); + setCalendarPage(null); + }} + onCurrentWeek={() => { + const nextKey = toLocalDateKey(new Date()); + setCalendarAnchorKey(nextKey); + setCalendarPage(null); + }} /> @@ -1037,27 +1184,29 @@ function MessageCard({ title, icon, items, + page, loading = false, emptyLabel, onOpen, + onRefresh, + refreshing, + onPreviousPage, + onNextPage, }: { title: string; icon: React.ReactNode; items: OutlookMessageSummary[]; + page: OutlookPageInfo | null; loading?: boolean; emptyLabel: string; onOpen: (item: OutlookMessageSummary) => void; + onRefresh: () => void; + refreshing: boolean; + onPreviousPage: () => void; + onNextPage: () => void; }) { - const pageSize = 8; - const [page, setPage] = useState(1); - const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); - const visibleItems = items.slice((page - 1) * pageSize, page * pageSize); - - useEffect(() => { - if (page > totalPages) { - setPage(totalPages); - } - }, [page, totalPages]); + const currentPage = page ? Math.floor(page.skip / Math.max(page.top, 1)) + 1 : 1; + const pageLabel = page ? `第 ${currentPage} 页 · 本页 ${page.returned} 封` : '正在读取邮件…'; return ( @@ -1067,26 +1216,24 @@ function MessageCard({ {icon} {title} -

{loading ? '正在读取邮件…' : `共 ${items.length} 封`}

+

{loading ? '正在读取邮件…' : pageLabel}

+ +
+ + +
- {!loading && totalPages > 1 ? ( -
- - - {page} / {totalPages} - - -
- ) : null} {loading ? ( @@ -1106,7 +1253,7 @@ function MessageCard({ ) : (
- {visibleItems.map((item) => ( + {items.map((item) => (
- +
+ + + + +
{loading ? ( diff --git a/app-instance/frontend/lib/api.ts b/app-instance/frontend/lib/api.ts index ab22153..2760b00 100644 --- a/app-instance/frontend/lib/api.ts +++ b/app-instance/frontend/lib/api.ts @@ -21,7 +21,9 @@ import type { OutlookConnectionPayload, OutlookConnectionTestResult, OutlookConnectResult, + OutlookEventListResponse, OutlookMessageDetail, + OutlookMessageListResponse, OutlookOverview, OutlookStatus, UiAgentDescriptor, @@ -838,6 +840,44 @@ export async function getOutlookOverview(): Promise { }); } +export async function getOutlookMessages( + folder: string, + options?: { + top?: number; + skip?: number; + unreadOnly?: boolean; + } +): Promise { + const params = new URLSearchParams({ + folder, + top: String(options?.top ?? 20), + skip: String(options?.skip ?? 0), + }); + if (options?.unreadOnly) { + params.set('unread_only', 'true'); + } + return fetchJSON(`/api/integrations/outlook/messages?${params.toString()}`, { + timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS, + }); +} + +export async function getOutlookEvents(options: { + startTime: string; + endTime: string; + top?: number; + skip?: number; +}): Promise { + const params = new URLSearchParams({ + start_time: options.startTime, + end_time: options.endTime, + top: String(options.top ?? 20), + skip: String(options.skip ?? 0), + }); + return fetchJSON(`/api/integrations/outlook/events?${params.toString()}`, { + timeoutMs: OUTLOOK_REQUEST_TIMEOUT_MS, + }); +} + export async function getOutlookMessageDetail( messageId: string, changekey?: string | null diff --git a/app-instance/frontend/types/index.ts b/app-instance/frontend/types/index.ts index aa538ae..0c7cd04 100644 --- a/app-instance/frontend/types/index.ts +++ b/app-instance/frontend/types/index.ts @@ -331,6 +331,14 @@ export interface OutlookEventSummary { organizer?: OutlookMailboxAddress | null; } +export interface OutlookPageInfo { + top: number; + skip: number; + returned: number; + has_more: boolean; + next_skip?: number | null; +} + export interface OutlookDefaultsFields { domain: string; service_endpoint: string; @@ -445,6 +453,20 @@ export interface OutlookOverview { meta: OutlookMeta; } +export interface OutlookMessageListResponse { + folder: string; + unread_only: boolean; + value: OutlookMessageSummary[]; + page: OutlookPageInfo; +} + +export interface OutlookEventListResponse { + start_time: string; + end_time: string; + value: OutlookEventSummary[]; + page: OutlookPageInfo; +} + export interface ProcessRun { run_id: string; parent_run_id?: string | null; diff --git a/auth-portal/src/app/api/runtime/register/route.ts b/auth-portal/src/app/api/runtime/register/route.ts index 124ca32..fde6351 100644 --- a/auth-portal/src/app/api/runtime/register/route.ts +++ b/auth-portal/src/app/api/runtime/register/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import type { TokenResponse } from '@/types/auth'; -import { HttpError, callAuthzService } from '@/lib/runtime-control'; +import { HttpError, REGISTER_REQUEST_TIMEOUT_MS, callAuthzService } from '@/lib/runtime-control'; function errorStatus(error: unknown): number { if (error instanceof HttpError) { @@ -36,7 +36,7 @@ export async function POST(request: NextRequest) { username, email, password, - }); + }, REGISTER_REQUEST_TIMEOUT_MS); return NextResponse.json(response); } catch (error) { diff --git a/auth-portal/src/lib/auth-client.ts b/auth-portal/src/lib/auth-client.ts index 8e5b1d1..4dd12e7 100644 --- a/auth-portal/src/lib/auth-client.ts +++ b/auth-portal/src/lib/auth-client.ts @@ -3,6 +3,7 @@ import type { TokenResponse } from '@/types/auth'; const REQUEST_TIMEOUT_MS = 8000; +const REGISTER_REQUEST_TIMEOUT_MS = 90000; function normalizeBaseUrl(value?: string | null): string | null { const trimmed = value?.trim(); @@ -26,9 +27,9 @@ function buildApiUrl(path: string): string { return path; } -async function fetchJSON(path: string, options?: RequestInit): Promise { +async function fetchJSON(path: string, options?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise { const controller = new AbortController(); - const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(buildApiUrl(path), { @@ -76,7 +77,7 @@ export async function register(username: string, email: string, password: string return fetchJSON('/api/runtime/register', { method: 'POST', body: JSON.stringify({ username, email, password }), - }); + }, REGISTER_REQUEST_TIMEOUT_MS); } export function buildFrontendHandoffUrl(response: TokenResponse, nextPath: string): string { diff --git a/auth-portal/src/lib/runtime-control.ts b/auth-portal/src/lib/runtime-control.ts index fca36d4..0edeeeb 100644 --- a/auth-portal/src/lib/runtime-control.ts +++ b/auth-portal/src/lib/runtime-control.ts @@ -4,6 +4,7 @@ const AUTHZ_API_BASE_URL = (process.env.AUTHZ_API_BASE_URL || 'http://127.0.0.1: const DEPLOY_API_BASE_URL = (process.env.DEPLOY_API_BASE_URL || 'http://127.0.0.1:8090').trim().replace(/\/+$/, ''); const DEPLOY_API_TOKEN = (process.env.DEPLOY_API_TOKEN || '').trim(); const REQUEST_TIMEOUT_MS = 15000; +const REGISTER_REQUEST_TIMEOUT_MS = 90000; type JsonObject = Record; @@ -24,9 +25,9 @@ function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } -async function fetchJson(url: string, init?: RequestInit): Promise { +async function fetchJson(url: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { @@ -80,13 +81,15 @@ export async function callDeployControl(path: string, payload: JsonObject): P }); } -export async function callAuthzService(path: string, payload: JsonObject): Promise { +export async function callAuthzService(path: string, payload: JsonObject, timeoutMs = REQUEST_TIMEOUT_MS): Promise { return fetchJson(`${AUTHZ_API_BASE_URL}${path}`, { method: 'POST', body: JSON.stringify(payload), - }); + }, timeoutMs); } +export { REGISTER_REQUEST_TIMEOUT_MS }; + export async function callInstanceApi(apiBaseUrl: string, path: string, payload: JsonObject): Promise { const baseUrl = apiBaseUrl.trim().replace(/\/+$/, ''); if (!baseUrl) { diff --git a/authz-service/src/README.md b/authz-service/src/README.md index 48dac25..a9f60d9 100644 --- a/authz-service/src/README.md +++ b/authz-service/src/README.md @@ -354,6 +354,9 @@ a2a:planner - 如果不传 `scope/scopes`,服务会返回该 audience 下允许的全部 scope - 如果传了 `scope/scopes`,必须是允许 scope 的子集,否则返回 `403 Requested scopes exceed backend permissions` - 如果 audience 未启用,返回 `403 Audience is not enabled for this backend` +- 当前默认开启 `AUTHZ_MCP_PERMISSIVE_DEFAULT=1` + - 对 `mcp:*` audience,会优先放行本次请求里声明的 scopes,并自动补 `list_tools` + - 如果你后面要改回严格模式,把这个环境变量设成 `0` ### Token 内省:`POST /oauth/introspect` diff --git a/authz-service/src/app/main.py b/authz-service/src/app/main.py index a75f156..6b5477c 100644 --- a/authz-service/src/app/main.py +++ b/authz-service/src/app/main.py @@ -39,7 +39,8 @@ ACCESS_TOKEN_TTL_SECONDS = int(os.getenv("AUTHZ_ACCESS_TOKEN_TTL_SECONDS", "3600 PRIVATE_KEY_PATH = Path(os.getenv("AUTHZ_PRIVATE_KEY_PATH", DATA_DIR / "signing_key.pem")) DEPLOY_API_BASE_URL = os.getenv("DEPLOY_API_BASE_URL", "http://127.0.0.1:8090").rstrip("/") DEPLOY_API_TOKEN = os.getenv("DEPLOY_API_TOKEN", "").strip() -UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("AUTHZ_UPSTREAM_TIMEOUT_SECONDS", "15")) +UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("AUTHZ_UPSTREAM_TIMEOUT_SECONDS", "90")) +MCP_PERMISSIVE_DEFAULT = os.getenv("AUTHZ_MCP_PERMISSIVE_DEFAULT", "1").strip() not in {"0", "false", "False"} store = JsonStore(DATA_DIR) signer = JwtSigner(PRIVATE_KEY_PATH, ISSUER, ACCESS_TOKEN_TTL_SECONDS) @@ -235,10 +236,14 @@ def _issue_token(payload: OAuthTokenRequest) -> OAuthTokenResponse: raise HTTPException(status_code=403, detail="Backend is disabled") allowed = _allowed_scopes_for_audience(credential.backend_id, payload.aud) + requested = {item.strip() for item in (payload.scopes or []) if isinstance(item, str) and item.strip()} + if payload.aud.startswith("mcp:") and MCP_PERMISSIVE_DEFAULT: + allowed = set(allowed) + allowed.add("list_tools") + allowed.update(requested) if not allowed: raise HTTPException(status_code=403, detail="Audience is not enabled for this backend") - requested = set(payload.scopes or []) if requested: if not requested.issubset(allowed): raise HTTPException(status_code=403, detail="Requested scopes exceed backend permissions") diff --git a/deploy-control/.env.example b/deploy-control/.env.example index 812c68e..d1f0c65 100644 --- a/deploy-control/.env.example +++ b/deploy-control/.env.example @@ -14,6 +14,8 @@ APP_INSTANCE_API_BASE= # Used as a fallback when authz-service does not explicitly pass authz_base_url. DEFAULT_AUTHZ_BASE_URL=http://nano-authz-service:19090 +DEFAULT_AUTHZ_OUTLOOK_MCP_URL= +DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp DEPLOY_PUBLIC_SCHEME=http DEPLOY_PUBLIC_BASE_DOMAIN=203.0.113.10.nip.io diff --git a/deploy-control/Dockerfile b/deploy-control/Dockerfile index 33a4d0c..6b6ea13 100644 --- a/deploy-control/Dockerfile +++ b/deploy-control/Dockerfile @@ -1,7 +1,14 @@ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends docker.io \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg \ + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \ + && chmod a+r /etc/apt/keyrings/docker.gpg \ + && . /etc/os-release \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends docker-ce-cli \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/deploy-control/README.md b/deploy-control/README.md index 16c1bdb..f4af6b7 100644 --- a/deploy-control/README.md +++ b/deploy-control/README.md @@ -18,6 +18,8 @@ - `DEPLOY_CONTROL_API_TOKEN` - `APP_INSTANCE_API_KEY` - `DEFAULT_AUTHZ_BASE_URL` +- `DEFAULT_AUTHZ_OUTLOOK_MCP_URL` +- `DEFAULT_OUTLOOK_MCP_SERVER_ID` - `DEPLOY_PUBLIC_BASE_DOMAIN` - `DEPLOY_PUBLIC_PORT` - `DEPLOY_PUBLIC_SCHEME` @@ -35,6 +37,15 @@ http://.127.0.0.1.nip.io:8088 实例容器本身的 `20000-29999` 端口默认只绑定到部署机 `127.0.0.1`,外部入口应走 `router-proxy`。 +如果你希望所有新实例默认带 Outlook MCP HTTP 工具,可以设置: + +```bash +DEFAULT_AUTHZ_OUTLOOK_MCP_URL=http://10.6.80.29:8000/mcp +DEFAULT_OUTLOOK_MCP_SERVER_ID=outlook_mcp +``` + +这样 `deploy-control` 创建的新实例会自动写入一条默认 MCP server 配置,并默认使用 `oauth_backend_token` + `mcp:` 的 audience。 + ## 本机启动 ```bash @@ -51,3 +62,30 @@ uv run server.py - `/home/ivan/xuan/nano_project/router-proxy` 并传入对应环境变量,让容器内脚本路径仍能访问这两个目录。 + +关键点: + +- 宿主机路径要原样挂进容器,不要改挂载目标路径 +- 同时显式传 `APP_INSTANCE_DIR` 和 `ROUTER_PROXY_DIR` + +示例: + +```bash +docker run -d \ + --name nano-deploy-control \ + --restart unless-stopped \ + --network nano-instance-edge \ + -p 8090:8090 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /home/ivan/xuan/nano_project/app-instance:/home/ivan/xuan/nano_project/app-instance \ + -v /home/ivan/xuan/nano_project/router-proxy:/home/ivan/xuan/nano_project/router-proxy \ + -e APP_INSTANCE_DIR=/home/ivan/xuan/nano_project/app-instance \ + -e ROUTER_PROXY_DIR=/home/ivan/xuan/nano_project/router-proxy \ + -e DEPLOY_CONTROL_API_TOKEN=change-me \ + -e APP_INSTANCE_IMAGE=nano/app-instance:latest \ + -e APP_INSTANCE_NETWORK_NAME=nano-instance-edge \ + -e APP_INSTANCE_API_KEY=sk-xxxxxxxx \ + nano/deploy-control:latest +``` + +如果这里错把宿主机目录映射成容器内的另一个短路径,例如 `/app-instance`,那么 `deploy-control` 通过 Docker socket 创建实例时会把错误路径传给 Docker,最终导致实例容器拿不到 `config.json` 并持续重启。 diff --git a/deploy-control/server.py b/deploy-control/server.py index 972c158..9af815c 100755 --- a/deploy-control/server.py +++ b/deploy-control/server.py @@ -41,6 +41,8 @@ DEFAULT_MODEL = os.environ.get("APP_INSTANCE_MODEL", "openai/gpt-5").strip() DEFAULT_API_KEY = os.environ.get("APP_INSTANCE_API_KEY", "").strip() DEFAULT_API_BASE = os.environ.get("APP_INSTANCE_API_BASE", "").strip() DEFAULT_AUTHZ_BASE_URL = os.environ.get("DEFAULT_AUTHZ_BASE_URL", "").strip() +DEFAULT_AUTHZ_OUTLOOK_MCP_URL = os.environ.get("DEFAULT_AUTHZ_OUTLOOK_MCP_URL", "").strip() +DEFAULT_OUTLOOK_MCP_SERVER_ID = os.environ.get("DEFAULT_OUTLOOK_MCP_SERVER_ID", "outlook_mcp").strip() or "outlook_mcp" PUBLIC_SCHEME = os.environ.get("DEPLOY_PUBLIC_SCHEME", "http").strip() or "http" PUBLIC_BASE_DOMAIN = os.environ.get("DEPLOY_PUBLIC_BASE_DOMAIN", "127.0.0.1.nip.io").strip() PUBLIC_HOST_TEMPLATE = os.environ.get("DEPLOY_PUBLIC_HOST_TEMPLATE", "{slug}.{base_domain}").strip() @@ -48,6 +50,7 @@ PUBLIC_PORT = int(os.environ.get("DEPLOY_PUBLIC_PORT", "8088").strip() or "8088" AUTO_START_PROXY = os.environ.get("DEPLOY_AUTO_START_PROXY", "1").strip() not in {"0", "false", "False"} HEALTH_TIMEOUT_SECONDS = float(os.environ.get("DEPLOY_HEALTH_TIMEOUT_SECONDS", "60").strip() or "60") HEALTH_INTERVAL_SECONDS = float(os.environ.get("DEPLOY_HEALTH_INTERVAL_SECONDS", "1").strip() or "1") +INSTANCE_INTERNAL_PORT = int(os.environ.get("APP_INSTANCE_INTERNAL_PORT", "8080").strip() or "8080") SERVER_HOST = os.environ.get("DEPLOY_CONTROL_HOST", "0.0.0.0").strip() or "0.0.0.0" SERVER_PORT = int(os.environ.get("DEPLOY_CONTROL_PORT", "8090").strip() or "8090") @@ -154,22 +157,37 @@ def build_public_url(host: str) -> str: return f"{PUBLIC_SCHEME}://{netloc}" +def build_internal_api_base_url(record: dict[str, Any]) -> str: + container_name = str(record.get("container_name", "") or "").strip() + if container_name: + return f"http://{container_name}:{INSTANCE_INTERNAL_PORT}" + fallback = str(record.get("api_base_url", "") or record.get("public_url", "") or "").strip() + return fallback + + def wait_for_backend(record: dict[str, Any]) -> None: host_port = int(record.get("host_port", 0) or 0) - if host_port <= 0: - raise ApiError(HTTPStatus.BAD_GATEWAY, "instance host port missing from registry") + container_name = str(record.get("container_name", "") or "").strip() + targets: list[str] = [] + if container_name: + targets.append(f"http://{container_name}:{INSTANCE_INTERNAL_PORT}/api/ping") + if host_port > 0: + targets.append(f"http://127.0.0.1:{host_port}/api/ping") + if not targets: + raise ApiError(HTTPStatus.BAD_GATEWAY, "instance health target missing from registry") + deadline = time.time() + HEALTH_TIMEOUT_SECONDS - target = f"http://127.0.0.1:{host_port}/api/ping" last_error = "backend not ready" while time.time() < deadline: - try: - with urllib_request.urlopen(target, timeout=5) as response: - payload = json.loads(response.read().decode("utf-8")) - if payload.get("message") == "pong": - return - last_error = f"unexpected ping response from {target}" - except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc: - last_error = str(exc) + for target in targets: + try: + with urllib_request.urlopen(target, timeout=5) as response: + payload = json.loads(response.read().decode("utf-8")) + if payload.get("message") == "pong": + return + last_error = f"unexpected ping response from {target}" + except (urllib_error.URLError, TimeoutError, json.JSONDecodeError) as exc: + last_error = f"{target}: {exc}" time.sleep(HEALTH_INTERVAL_SECONDS) raise ApiError(HTTPStatus.BAD_GATEWAY, f"instance health check failed: {last_error}") @@ -197,6 +215,9 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]: api_key = str(payload.get("api_key", "") or DEFAULT_API_KEY).strip() api_base = str(payload.get("api_base", "") or DEFAULT_API_BASE).strip() authz_base_url = str(payload.get("authz_base_url", "") or DEFAULT_AUTHZ_BASE_URL).strip() + authz_outlook_mcp_url = str( + payload.get("authz_outlook_mcp_url", "") or DEFAULT_AUTHZ_OUTLOOK_MCP_URL + ).strip() backend_name = str(payload.get("backend_name", "") or username).strip() or username image_name = str(payload.get("image_name", "") or INSTANCE_IMAGE).strip() or INSTANCE_IMAGE @@ -236,6 +257,9 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]: command.extend(["--api-base", api_base]) if authz_base_url: command.extend(["--authz-base-url", authz_base_url]) + if authz_outlook_mcp_url: + command.extend(["--authz-outlook-mcp-url", authz_outlook_mcp_url]) + command.extend(["--outlook-mcp-server-id", DEFAULT_OUTLOOK_MCP_SERVER_ID]) if payload.get("replace") is True: command.append("--replace") @@ -254,7 +278,7 @@ def create_or_get_instance(payload: dict[str, Any]) -> dict[str, Any]: "instance": existing, "public_url": str(existing.get("public_url", "") or ""), "frontend_base_url": str(existing.get("frontend_base_url", "") or existing.get("public_url", "") or ""), - "api_base_url": str(existing.get("api_base_url", "") or existing.get("public_url", "") or ""), + "api_base_url": build_internal_api_base_url(existing), } @@ -269,7 +293,7 @@ def resolve_instance(payload: dict[str, Any]) -> dict[str, Any]: "instance": record, "public_url": str(record.get("public_url", "") or ""), "frontend_base_url": str(record.get("frontend_base_url", "") or record.get("public_url", "") or ""), - "api_base_url": str(record.get("api_base_url", "") or record.get("public_url", "") or ""), + "api_base_url": build_internal_api_base_url(record), } diff --git a/域名配置指引.md b/域名配置指引.md new file mode 100644 index 0000000..e584db9 --- /dev/null +++ b/域名配置指引.md @@ -0,0 +1,395 @@ +# nano_project 域名配置指引 + +这份文档专门解释一件事: + +- 如果你不用 `127.0.0.1.nip.io` +- 想换成自己的正式域名 +- 应该怎么理解、怎么配、该改哪些地方 + +先说最重要的结论: + +- `DNS` 只管把域名解析到 `IP` +- `端口` 不归 DNS 管 +- 所以“域名配到哪个端口”本质上是反向代理或公网入口层在处理 + +也就是说: + +- 域名解析本身,是项目外部的事情 +- 但项目里生成出来的实例地址、门户地址,又会依赖你填的域名 + +所以这件事是: + +- 一半在系统外 +- 一半和系统配置有关 + +--- + +## 1. 先理解这套系统里每个端口是干什么的 + +当前默认端口职责: + +- `3081` + - `auth-portal` + - 用户注册、登录入口 +- `8088` + - `router-proxy` + - 所有用户实例统一入口 +- `8090` + - `deploy-control` + - 内部控制面 +- `19090` + - `authz-service` + - 内部鉴权服务 + +正常公网暴露建议: + +- 暴露 `3081` +- 暴露 `8088` +- 不要直接暴露 `8090` +- 不要直接暴露 `19090` + +--- + +## 2. 推荐的域名规划 + +最推荐这样分: + +- `portal.example.com` + - 给登录/注册页 +- `*.apps.example.com` + - 给用户实例 + +这样用户最终访问会像: + +```text +https://portal.example.com +https://alice.apps.example.com +https://bob.apps.example.com +``` + +其中: + +- `portal.example.com` 走 `auth-portal` +- `alice.apps.example.com` 走 `router-proxy` + +--- + +## 3. 只配 DNS 还不够 + +很多人最容易误解的是: + +“我把域名解析到服务器 IP,就等于已经配好了” + +这不对。 + +你还要解决: + +- 用户访问 `80/443` 时,流量先进谁 +- 谁把流量转到 `3081` +- 谁把流量转到 `8088` + +所以正式域名一般至少要有两层: + +### 第一层:DNS + +例如: + +- `portal.example.com` -> 服务器公网 IP +- `apps.example.com` -> 服务器公网 IP +- `*.apps.example.com` -> 服务器公网 IP + +### 第二层:公网反向代理 + +例如用: + +- Nginx +- Caddy +- Traefik +- 云负载均衡 + +它负责: + +- 监听公网 `80/443` +- 根据域名把请求转发到本机不同端口 + +--- + +## 4. 最直接的映射关系 + +如果你先不做 HTTPS,只做最基础的 HTTP: + +- `portal.example.com` -> 转发到 `127.0.0.1:3081` +- `*.apps.example.com` -> 转发到 `127.0.0.1:8088` + +也就是: + +```text +portal.example.com -> auth-portal -> 3081 +*.apps.example.com -> router-proxy -> 8088 +``` + +注意: + +- `router-proxy` 是靠 `Host` 头识别具体实例的 +- 所以必须把原始 Host 透传过去 + +--- + +## 5. 这个项目内部哪些值要改 + +如果你要从 `127.0.0.1.nip.io` 换成正式域名,至少要改这些: + +### 本机部署变量里 + +把: + +```bash +export NANO_BASE_DOMAIN=127.0.0.1.nip.io +``` + +改成: + +```bash +export NANO_BASE_DOMAIN=apps.example.com +``` + +这样以后新创建的实例 URL 才会变成: + +```text +http://alice.apps.example.com:8088 +``` + +如果你后面还有外层 `80/443` 代理,不想让用户看到 `:8088`,那还需要额外调整入口层做无端口访问转发。 + +### `deploy-control` 里实际影响实例地址的变量 + +它们是: + +- `DEPLOY_PUBLIC_SCHEME` +- `DEPLOY_PUBLIC_BASE_DOMAIN` +- `DEPLOY_PUBLIC_PORT` + +例如: + +```bash +-e DEPLOY_PUBLIC_SCHEME="https" \ +-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \ +-e DEPLOY_PUBLIC_PORT="443" \ +``` + +或者如果你暂时还是明文 HTTP: + +```bash +-e DEPLOY_PUBLIC_SCHEME="http" \ +-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \ +-e DEPLOY_PUBLIC_PORT="8088" \ +``` + +--- + +## 6. 什么时候可以把端口从 URL 里去掉 + +如果你希望用户访问: + +```text +https://alice.apps.example.com +``` + +而不是: + +```text +http://alice.apps.example.com:8088 +``` + +那你需要满足这两个条件: + +1. 外层已经有监听 `80/443` 的反向代理 +2. 它已经把 `*.apps.example.com` 转发到本机 `8088` + +这时项目内部就应该写: + +```bash +DEPLOY_PUBLIC_SCHEME=https +DEPLOY_PUBLIC_BASE_DOMAIN=apps.example.com +DEPLOY_PUBLIC_PORT=443 +``` + +或者很多时候你也可以直接在显示层隐藏默认端口概念,让用户只看标准 `https` 地址。 + +--- + +## 7. 一套推荐的正式域名方案 + +假设你有: + +- 门户域名:`portal.example.com` +- 实例根域名:`apps.example.com` + +推荐这样做: + +### 项目内部 + +`deploy-control`: + +```bash +-e DEPLOY_PUBLIC_SCHEME="https" \ +-e DEPLOY_PUBLIC_BASE_DOMAIN="apps.example.com" \ +-e DEPLOY_PUBLIC_PORT="443" \ +``` + +本机部署变量: + +```bash +export NANO_BASE_DOMAIN=apps.example.com +``` + +### 项目外部 + +DNS: + +- `portal.example.com` -> 服务器 IP +- `apps.example.com` -> 服务器 IP +- `*.apps.example.com` -> 服务器 IP + +公网代理: + +- `portal.example.com` -> `127.0.0.1:3081` +- `*.apps.example.com` -> `127.0.0.1:8088` + +--- + +## 8. 一个常见误区 + +### 误区 1 + +“我把 `portal.example.com` 配给 `8088` 可以吗?” + +技术上能转,但不推荐。 + +因为: + +- `8088` 是实例入口 +- `3081` 才是门户入口 + +更清晰的职责划分应该是: + +- 门户 -> `3081` +- 实例 -> `8088` + +### 误区 2 + +“我能不能把 `8090` 和 `19090` 也直接开放给公网?” + +不建议。 + +因为: + +- `8090` 是内部部署控制面 +- `19090` 是内部鉴权服务 + +这两个应该尽量只允许容器网络或内网访问。 + +### 误区 3 + +“DNS 能不能直接决定端口?” + +不能。 + +DNS 只能决定: + +- 域名 -> IP + +端口是: + +- 浏览器默认端口规则 +- URL 里显式写端口 +- 反向代理转发规则 + +共同决定的。 + +--- + +## 9. 最简单的理解方式 + +把它拆成两件事就不容易乱: + +### 系统内的事 + +这个项目要知道: + +- 实例公网地址长什么样 +- 新实例生成什么域名 +- 对外协议是 `http` 还是 `https` + +所以它关心: + +- `DEPLOY_PUBLIC_SCHEME` +- `DEPLOY_PUBLIC_BASE_DOMAIN` +- `DEPLOY_PUBLIC_PORT` + +### 系统外的事 + +你的服务器或云环境要负责: + +- 域名解析 +- TLS 证书 +- 80/443 入口 +- 把请求转给 `3081` 或 `8088` + +--- + +## 10. 如果你现在只是本机测试 + +那你可以完全先不管正式域名。 + +继续用: + +```bash +export NANO_BASE_DOMAIN=127.0.0.1.nip.io +``` + +这已经足够验证整个系统: + +- 注册 +- 登录 +- 创建实例 +- 跳转个人实例 + +等你准备真正对外给别人访问时,再处理正式域名和 HTTPS。 + +--- + +## 11. 一句话结论 + +如果你问: + +“域名应该配到什么端口上?” + +最实用的答案是: + +- 门户域名 -> `3081` +- 实例泛域名 -> `8088` +- `8090` 和 `19090` 不建议直接公开 + +但更准确地说: + +- 域名解析本身不带端口 +- 真正的端口转发,是由外层反向代理做的 + +--- + +## 12. 你后面最可能要补的东西 + +如果你准备上正式域名,下一步通常是补下面其中一个: + +- `Nginx` 反向代理配置 +- `Caddy` 配置 +- 云负载均衡转发规则 +- HTTPS 证书配置 + +如果你要,我下一步可以继续给你补: + +- `Nginx 域名反代示例.md` +- 或者 `Caddy 域名反代示例.md` + +都可以直接按这个项目的端口结构来写。 diff --git a/部署指南.md b/部署指南.md new file mode 100644 index 0000000..8cd9b25 --- /dev/null +++ b/部署指南.md @@ -0,0 +1,618 @@ +# nano_project 本机一步步部署指南 + +这份文档适合第一次在本机把整个项目跑起来的人,目标是: + +- 在一台 `Linux` 或 `WSL2 Ubuntu` 机器上 +- 用 `Docker` 跑完整链路 +- 最后能在浏览器里注册账号,并自动创建你的专属实例 + +这套项目当前的推荐本机测试方式是: + +- `auth-portal` +- `authz-service` +- `deploy-control` +- `router-proxy` +- `app-instance` + +全部一起跑。 + +如果你只单独跑某个前端页面,页面能打开,但注册、登录、创建实例这些核心能力不一定会通。 + +--- + +## 0. 先说前提 + +### 适合的环境 + +推荐: + +- Linux +- WSL2 Ubuntu + +不推荐直接按这份文档在纯 Windows 命令行里照抄,因为这里依赖: + +- Docker +- Bash 脚本 +- Docker Socket 挂载 +- 宿主机目录挂载 + +### 你需要先装好的工具 + +- `docker` +- `git` +- `curl` +- `openssl` +- `python3` + +先检查: + +```bash +docker --version +docker ps +python3 --version +openssl version +curl --version +``` + +如果 `docker ps` 报错,先把 Docker 启动起来。 + +--- + +## 1. 进入项目根目录 + +```bash +cd /home/ivan/xuan/nano_project +``` + +你执行完以后,建议顺手确认一下当前目录: + +```bash +pwd +``` + +你应该看到: + +```text +/home/ivan/xuan/nano_project +``` + +--- + +## 2. 准备一套本机测试变量 + +### 为什么这里用 `127.0.0.1.nip.io` + +因为这是最省事的本机测试域名方案。 + +它的作用是: + +- `alice.127.0.0.1.nip.io` +- 自动解析到 `127.0.0.1` + +这样 `router-proxy` 就能按子域名区分不同实例。 + +### 直接复制执行 + +```bash +export PROJECT_ROOT=/home/ivan/xuan/nano_project +export NANO_NET=nano-instance-edge + +export NANO_DEPLOY_TOKEN="$(openssl rand -hex 32)" +export NANO_AUTHZ_INTERNAL_TOKEN="$(openssl rand -hex 32)" + +export NANO_SERVER_IP=127.0.0.1 +export NANO_BASE_DOMAIN=127.0.0.1.nip.io + +export NANO_PROVIDER=openai +export NANO_MODEL=openai/gpt-5 +export NANO_API_KEY='把这里换成你自己的模型 API Key' +export NANO_API_BASE='' + +export NANO_AUTHZ_URL='http://nano-authz-service:19090' +export NANO_OUTLOOK_MCP_URL='' +export NANO_OUTLOOK_MCP_SERVER_ID='outlook_mcp' +export NANO_DEPLOY_URL='http://nano-deploy-control:8090' +``` + +### 这里每个变量大概是干什么的 + +- `PROJECT_ROOT` + - 仓库根目录 +- `NANO_NET` + - 所有容器共用的 Docker 网络 +- `NANO_DEPLOY_TOKEN` + - `auth-portal` / `authz-service` 调 `deploy-control` 时的鉴权 token +- `NANO_AUTHZ_INTERNAL_TOKEN` + - AuthZ 内部接口 token +- `NANO_BASE_DOMAIN` + - 实例基础域名 +- `NANO_PROVIDER` + - 新实例默认模型提供商 +- `NANO_MODEL` + - 新实例默认模型 +- `NANO_API_KEY` + - 新实例默认模型 API Key +- `NANO_API_BASE` + - 自定义模型网关地址,没有就留空 +- `NANO_AUTHZ_URL` + - 容器网络内访问 AuthZ 的地址 +- `NANO_DEPLOY_URL` + - 容器网络内访问 deploy-control 的地址 +- `NANO_OUTLOOK_MCP_URL` + - 可选;如果你有独立 Outlook MCP 服务,可以在这里填 +- `NANO_OUTLOOK_MCP_SERVER_ID` + - Outlook MCP 默认 server id,当前推荐固定 `outlook_mcp` + +### 一个特别重要的提醒 + +`NANO_API_KEY` 不能空着。 + +如果这里不填,新用户注册时虽然页面可能能走到一半,但自动创建 `app-instance` 时大概率失败,因为实例配置里需要 `APP_INSTANCE_API_KEY`。 + +--- + +## 3. 创建运行目录 + +```bash +mkdir -p \ + "$PROJECT_ROOT/authz-service/runtime/data" \ + "$PROJECT_ROOT/app-instance/runtime/instances" \ + "$PROJECT_ROOT/app-instance/runtime/registry" \ + "$PROJECT_ROOT/router-proxy/runtime/conf.d" +``` + +这一步的作用是给下面几个东西留持久化空间: + +- AuthZ 数据 +- 实例注册表 +- 每个用户实例的配置目录 +- router-proxy 生成出来的路由文件 + +--- + +## 4. 构建镜像 + +第一次构建会比较久,正常情况要等几分钟。 + +```bash +cd "$PROJECT_ROOT" + +docker build -t nano/app-instance:latest app-instance +docker build -t nano/authz-service:latest authz-service +docker build -t nano/deploy-control:latest deploy-control +docker build -t nano/auth-portal:latest auth-portal/src +``` + +如果中间有某个镜像失败,不要继续往下跑,先把失败那一步修掉。 + +常见失败原因: + +- Docker 没启动 +- 网络拉镜像失败 +- 你的本机磁盘空间不够 + +--- + +## 5. 创建共享 Docker 网络 + +```bash +docker network inspect "$NANO_NET" >/dev/null 2>&1 || docker network create "$NANO_NET" +``` + +执行完后可确认: + +```bash +docker network ls | grep "$NANO_NET" +``` + +应该能看到: + +```text +nano-instance-edge +``` + +--- + +## 6. 启动统一入口代理 `router-proxy` + +```bash +cd "$PROJECT_ROOT" + +PROXY_NETWORK_NAME="$NANO_NET" \ +PROXY_HTTP_PORT=8088 \ +./router-proxy/start-proxy.sh --replace +``` + +启动后,统一入口走: + +```text +http://<你的实例slug>.127.0.0.1.nip.io:8088 +``` + +例如: + +```text +http://alice.127.0.0.1.nip.io:8088 +``` + +--- + +## 7. 启动 `authz-service` + +```bash +docker rm -f nano-authz-service >/dev/null 2>&1 || true + +docker run -d \ + --name nano-authz-service \ + --restart unless-stopped \ + --network "$NANO_NET" \ + -p 19090:19090 \ + -v "$PROJECT_ROOT/authz-service/runtime/data:/var/lib/authz-service/data" \ + -e AUTHZ_ISSUER="$NANO_AUTHZ_URL" \ + -e AUTHZ_INTERNAL_TOKEN="$NANO_AUTHZ_INTERNAL_TOKEN" \ + -e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \ + -e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \ + nano/authz-service:latest +``` + +### 这里有个很关键的坑 + +`AUTHZ_ISSUER` 这里不能写成: + +```text +http://127.0.0.1:19090 +``` + +因为后面新创建的 `app-instance` 也是通过 Docker 网络去访问 AuthZ 的。 + +所以这里要写容器网络里可访问的地址: + +```text +http://nano-authz-service:19090 +``` + +--- + +## 8. 启动 `deploy-control` + +这一步最容易配错的点是挂载目录。 + +一定要注意: + +- `app-instance` 和 `router-proxy` 的宿主机路径,要按原路径挂进容器 +- 不能偷懒挂到容器里的另一个短路径比如 `/app-instance` +- 同时要把 `APP_INSTANCE_DIR` 和 `ROUTER_PROXY_DIR` 也明确传进去 + +直接执行: + +```bash +docker rm -f nano-deploy-control >/dev/null 2>&1 || true + +docker run -d \ + --name nano-deploy-control \ + --restart unless-stopped \ + --network "$NANO_NET" \ + -p 8090:8090 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$PROJECT_ROOT/app-instance:$PROJECT_ROOT/app-instance" \ + -v "$PROJECT_ROOT/router-proxy:$PROJECT_ROOT/router-proxy" \ + -e APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance" \ + -e ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy" \ + -e DEPLOY_CONTROL_API_TOKEN="$NANO_DEPLOY_TOKEN" \ + -e APP_INSTANCE_IMAGE="nano/app-instance:latest" \ + -e APP_INSTANCE_NETWORK_NAME="$NANO_NET" \ + -e APP_INSTANCE_PROVIDER="$NANO_PROVIDER" \ + -e APP_INSTANCE_MODEL="$NANO_MODEL" \ + -e APP_INSTANCE_API_KEY="$NANO_API_KEY" \ + -e APP_INSTANCE_API_BASE="$NANO_API_BASE" \ + -e DEFAULT_AUTHZ_BASE_URL="$NANO_AUTHZ_URL" \ + -e DEFAULT_AUTHZ_OUTLOOK_MCP_URL="$NANO_OUTLOOK_MCP_URL" \ + -e DEFAULT_OUTLOOK_MCP_SERVER_ID="$NANO_OUTLOOK_MCP_SERVER_ID" \ + -e DEPLOY_PUBLIC_SCHEME="http" \ + -e DEPLOY_PUBLIC_BASE_DOMAIN="$NANO_BASE_DOMAIN" \ + -e DEPLOY_PUBLIC_PORT="8088" \ + -e DEPLOY_AUTO_START_PROXY="1" \ + nano/deploy-control:latest +``` + +### 这一步在做什么 + +`deploy-control` 会负责: + +- 收到“创建实例”的请求 +- 调用 `app-instance/create-instance.sh` +- 通过 Docker 创建对应用户实例 +- 刷新 `router-proxy` + +--- + +## 9. 启动 `auth-portal` + +```bash +docker rm -f nano-auth-portal >/dev/null 2>&1 || true + +docker run -d \ + --name nano-auth-portal \ + --restart unless-stopped \ + --network "$NANO_NET" \ + -p 3081:3081 \ + -e AUTHZ_API_BASE_URL="$NANO_AUTHZ_URL" \ + -e DEPLOY_API_BASE_URL="$NANO_DEPLOY_URL" \ + -e DEPLOY_API_TOKEN="$NANO_DEPLOY_TOKEN" \ + nano/auth-portal:latest +``` + +这个页面就是用户看到的登录/注册入口。 + +--- + +## 10. 做健康检查 + +### 先检查接口 + +```bash +curl http://127.0.0.1:19090/healthz +curl http://127.0.0.1:8090/healthz +curl -I http://127.0.0.1:3081 +``` + +你应该大致看到: + +- `authz-service` 返回健康 JSON +- `deploy-control` 返回健康 JSON +- `auth-portal` 返回 `HTTP/1.1 200 OK` + +### 再看容器状态 + +```bash +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' +``` + +你至少应该能看到这些容器: + +- `nano-authz-service` +- `nano-deploy-control` +- `nano-auth-portal` +- `nano-router-proxy` + +### 再看一下代理日志 + +```bash +docker logs --tail=50 nano-router-proxy +``` + +如果这一步没有明显报错,就可以开始浏览器测试了。 + +--- + +## 11. 浏览器首次测试 + +打开: + +```text +http://127.0.0.1:3081/register +``` + +然后按顺序操作: + +1. 注册一个新账号 +2. 注册成功后,系统会自动创建一个你的专属实例 +3. 浏览器应该跳到你的实例地址 + +跳转目标一般长这样: + +```text +http://你的slug.127.0.0.1.nip.io:8088 +``` + +例如: + +```text +http://alice.127.0.0.1.nip.io:8088 +``` + +--- + +## 12. 确认实例真的被创建出来了 + +```bash +cd "$PROJECT_ROOT/app-instance" +./list-instances.sh +./list-instances.sh --json +``` + +你应该能看到类似: + +- `instance_id` +- `instance_slug` +- `container_name` +- `public_url` + +以及对应的 `app-instance-` 容器。 + +你还可以继续查: + +```bash +docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance +``` + +--- + +## 13. 如果你只是想单独看前端页面 + +如果你只是想看 `auth-portal` 页面样子,不跑全链路,也可以单独启动它的前端开发模式: + +```bash +cd /home/ivan/xuan/nano_project/auth-portal/src +npm install +npm run dev +``` + +然后打开: + +```text +http://127.0.0.1:3081 +``` + +但是要注意: + +- 这只能看页面 +- 注册、登录、创建实例这些动作是否成功,仍然取决于 `authz-service` 和 `deploy-control` 有没有另外启动 + +--- + +## 14. 一键排错命令 + +如果你感觉“不对劲”,先跑这几条: + +```bash +docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' + +docker logs --tail=100 nano-authz-service +docker logs --tail=100 nano-deploy-control +docker logs --tail=100 nano-auth-portal +docker logs --tail=100 nano-router-proxy + +curl http://127.0.0.1:19090/healthz +curl http://127.0.0.1:8090/healthz +curl -I http://127.0.0.1:3081 +``` + +如果是实例创建失败,再加两条: + +```bash +cd "$PROJECT_ROOT/app-instance" +./list-instances.sh --json + +docker ps --format 'table {{.Names}}\t{{.Status}}' | grep app-instance +``` + +--- + +## 15. 最常见的坑 + +### 1. API Key 没填 + +现象: + +- 注册页面提交后创建实例失败 + +原因: + +- `APP_INSTANCE_API_KEY` 没有有效值 + +### 2. Docker 没启动 + +现象: + +- `deploy-control` 无法创建实例 +- 或 `docker ps` 本身就报错 + +### 3. `AUTHZ_ISSUER` 写成了 `127.0.0.1` + +错误写法: + +```text +http://127.0.0.1:19090 +``` + +正确写法: + +```text +http://nano-authz-service:19090 +``` + +原因: + +- 新实例容器里访问不到宿主机自己的 `127.0.0.1:19090` + +### 4. `deploy-control` 的路径挂载写错 + +错误思路: + +- 把宿主机的 `app-instance` 挂到容器里的 `/app-instance` + +正确思路: + +- 宿主机原路径挂进去 +- 并设置: + - `APP_INSTANCE_DIR="$PROJECT_ROOT/app-instance"` + - `ROUTER_PROXY_DIR="$PROJECT_ROOT/router-proxy"` + +### 5. `nip.io` 解析失败 + +如果实例跳转地址打不开,先试: + +```bash +ping 127.0.0.1.nip.io +``` + +如果你本地网络把 `nip.io` 拦了,这套子域名测试方式就会失效。 + +### 6. 端口被占用 + +默认会用到这些端口: + +- `3081` +- `8090` +- `19090` +- `8088` + +你可以先查: + +```bash +ss -ltnp | grep -E '3081|8090|19090|8088' +``` + +--- + +## 16. 如果你要重新来一遍 + +如果你只是想“重新部署这四个基础容器”,可以先停掉它们: + +```bash +docker rm -f \ + nano-auth-portal \ + nano-authz-service \ + nano-deploy-control \ + nano-router-proxy 2>/dev/null || true +``` + +如果你还想把旧实例容器也一起清掉,再额外处理 `app-instance-*`。 + +注意: + +- 不要在你还需要旧数据的时候乱删 `runtime/` +- `authz-service/runtime/data` 和 `app-instance/runtime/instances` 里都有持久化数据 + +--- + +## 17. 本机部署成功后的结果应该是什么 + +如果整个流程正常,最后你会得到: + +- 一个可以打开的注册页: + - `http://127.0.0.1:3081/register` +- 一个统一实例入口代理: + - `http://.127.0.0.1.nip.io:8088` +- 一个能自动创建用户专属容器的部署控制面 +- 一份实例注册表: + - `app-instance/runtime/registry/instances.json` + +--- + +## 18. 你下一步最建议做什么 + +第一次建议这样测: + +1. 用全新用户名注册一个测试账号 +2. 确认浏览器跳到了你的实例 URL +3. 再执行 `./app-instance/list-instances.sh --json` +4. 确认注册表里真的有这条实例记录 + +如果你后面想要,我还可以继续补两份文档: + +- `服务器部署指南.md` + - 面向公网服务器、固定 IP、长期运行 +- `常见报错排查.md` + - 专门收集 502、超时、实例起不来、MCP 鉴权失败这类问题