Refactor app instance to Keycloak SSO
This commit is contained in:
@ -1,4 +1,5 @@
|
||||
runtime/
|
||||
.env
|
||||
backend/.git/
|
||||
backend/.venv/
|
||||
backend/.pytest_cache/
|
||||
@ -13,4 +14,3 @@ frontend/node_modules/
|
||||
frontend/.next/
|
||||
frontend/.next-dev/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
|
||||
28
app-instance/.env.example
Normal file
28
app-instance/.env.example
Normal file
@ -0,0 +1,28 @@
|
||||
# Docker image/runtime
|
||||
IMAGE_NAME=beaver/app-instance:keycloak
|
||||
CONTAINER_NAME=beaver-app-instance
|
||||
HOST_IP=172.19.0.245
|
||||
HOST_PORT=18080
|
||||
HOST_BIND_IP=0.0.0.0
|
||||
DATA_DIR=runtime/standalone
|
||||
DOCKER_NETWORK=
|
||||
|
||||
# Beaver model config rendered into runtime/standalone/beaver-home/config.json
|
||||
BEAVER_PROVIDER=custom
|
||||
BEAVER_MODEL=
|
||||
BEAVER_API_KEY=
|
||||
BEAVER_API_BASE=
|
||||
|
||||
# Keycloak public client
|
||||
KEYCLOAK_ISSUER=https://keycloak.bwgdi.com/realms/beaver
|
||||
KEYCLOAK_CLIENT_ID=beaver-agnet
|
||||
KEYCLOAK_REDIRECT_URI=
|
||||
KEYCLOAK_POST_LOGOUT_REDIRECT_URI=
|
||||
|
||||
# Build mirrors
|
||||
NPM_REGISTRY=https://registry.npmmirror.com
|
||||
NPM_FETCH_RETRIES=5
|
||||
NPM_FETCH_RETRY_MIN_TIMEOUT=20000
|
||||
NPM_FETCH_RETRY_MAX_TIMEOUT=120000
|
||||
APT_MIRROR=https://mirrors.tuna.tsinghua.edu.cn/debian
|
||||
PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
@ -20,11 +20,15 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
|
||||
COPY frontend/ ./
|
||||
|
||||
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=""
|
||||
ARG NEXT_PUBLIC_AUTH_PORTAL_PORT="3081"
|
||||
ARG NEXT_PUBLIC_KEYCLOAK_ISSUER="https://keycloak.bwgdi.com/realms/beaver"
|
||||
ARG NEXT_PUBLIC_KEYCLOAK_CLIENT_ID="beaver-agnet"
|
||||
ARG NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI=""
|
||||
ARG NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=""
|
||||
|
||||
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=${NEXT_PUBLIC_AUTH_PORTAL_URL}
|
||||
ENV NEXT_PUBLIC_AUTH_PORTAL_PORT=${NEXT_PUBLIC_AUTH_PORTAL_PORT}
|
||||
ENV NEXT_PUBLIC_KEYCLOAK_ISSUER=${NEXT_PUBLIC_KEYCLOAK_ISSUER}
|
||||
ENV NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=${NEXT_PUBLIC_KEYCLOAK_CLIENT_ID}
|
||||
ENV NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI=${NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI}
|
||||
ENV NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=${NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI}
|
||||
|
||||
# API / WS 走同域反代,不在构建时写死实例地址。
|
||||
RUN npm run build
|
||||
@ -39,7 +43,6 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
||||
BEAVER_HOME=/root/.beaver \
|
||||
BEAVER_CONFIG_PATH=/root/.beaver/config.json \
|
||||
BEAVER_WORKSPACE=/root/.beaver/workspace \
|
||||
BEAVER_AUTH_FILE=/root/.beaver/web_auth_users.json \
|
||||
PORT=3000 \
|
||||
HOSTNAME=127.0.0.1
|
||||
|
||||
|
||||
@ -1,120 +1,56 @@
|
||||
# app-instance
|
||||
|
||||
单实例应用单元:
|
||||
|
||||
- 一个 Docker 容器里同时运行前端、后端和 Nginx 反代
|
||||
- 前端走 `/`
|
||||
- 后端 API 走 `/api`
|
||||
- WebSocket 走 `/ws`
|
||||
单体 app instance:一个 Docker 容器内运行前端、后端和 Nginx。这个目录只提供干净的应用实例,不再负责用户注册登录、实例编排、注册表或反向代理路由。
|
||||
|
||||
## 关键文件
|
||||
|
||||
- `Dockerfile`
|
||||
- 统一镜像构建入口
|
||||
- `entrypoint.sh`
|
||||
- 容器内启动前端、后端、Nginx
|
||||
- `create-instance.sh`
|
||||
- 创建实例目录、生成配置、启动容器、写注册表
|
||||
- `remove-instance.sh`
|
||||
- 删除容器、移除注册表、可选清理实例目录
|
||||
- `list-instances.sh`
|
||||
- 查看当前注册实例
|
||||
- `instance-registry.py`
|
||||
- 维护 `runtime/registry/instances.json`
|
||||
- `Dockerfile`: 统一镜像构建入口
|
||||
- `entrypoint.sh`: 容器内启动前端、后端、Nginx
|
||||
- `run-standalone.sh`: 从 `.env` 读取参数,构建并运行单体容器
|
||||
- `.env.example`: 可复制的部署参数模板
|
||||
|
||||
## 注册表
|
||||
## Keycloak SSO
|
||||
|
||||
默认注册表路径:
|
||||
当前登录交给 Keycloak:
|
||||
|
||||
- issuer: `https://keycloak.bwgdi.com/realms/beaver`
|
||||
- client_id: `beaver-agnet`
|
||||
- flow: Authorization Code + PKCE S256
|
||||
- password grant: disabled
|
||||
- client secret: none
|
||||
|
||||
应用需要在 Keycloak client 中配置:
|
||||
|
||||
- `redirect_uri`: `http://<host-ip>:<host-port>/auth/callback`
|
||||
- `post_logout_redirect_uri`: `http://<host-ip>:<host-port>/logout/callback`
|
||||
- `web_origin`: `http://<host-ip>:<host-port>`
|
||||
|
||||
默认单机部署示例:
|
||||
|
||||
```text
|
||||
runtime/registry/instances.json
|
||||
redirect_uri: http://172.19.0.245:18080/auth/callback
|
||||
post_logout_redirect_uri: http://172.19.0.245:18080/logout/callback
|
||||
web_origin: http://172.19.0.245:18080
|
||||
```
|
||||
|
||||
每条记录至少包含:
|
||||
|
||||
- `instance_id`
|
||||
- `instance_slug`
|
||||
- `container_name`
|
||||
- `host_port`
|
||||
- `public_url`
|
||||
- `instance_root`
|
||||
- `image_name`
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 1. 构建镜像
|
||||
## 部署
|
||||
|
||||
```bash
|
||||
docker build -t beaver/app-instance:latest .
|
||||
cp .env.example .env
|
||||
./run-standalone.sh --build --replace
|
||||
```
|
||||
|
||||
### 2. 创建实例
|
||||
脚本会输出当前需要填到 Keycloak 的 `redirect_uri`、`post_logout_redirect_uri` 和 `web_origin`。
|
||||
|
||||
```bash
|
||||
./create-instance.sh \
|
||||
--image beaver/app-instance:latest \
|
||||
--instance-id demo-001 \
|
||||
--auth-username admin \
|
||||
--auth-password 123456 \
|
||||
--api-key 'your-api-key'
|
||||
```
|
||||
## 数据目录
|
||||
|
||||
可选参数:
|
||||
|
||||
- `--host-port`
|
||||
- `--public-url`
|
||||
- `--username`
|
||||
- `--email`
|
||||
- `--instance-host`
|
||||
- `--authz-base-url`
|
||||
- `--backend-id`
|
||||
- `--client-id`
|
||||
- `--client-secret`
|
||||
- `--network`
|
||||
- `--host-bind-ip`
|
||||
- `--initial-skills-dir`
|
||||
- `--skip-initial-skills`
|
||||
- `--build`
|
||||
- `--replace`
|
||||
|
||||
### 3. 查看实例
|
||||
|
||||
```bash
|
||||
./list-instances.sh
|
||||
./list-instances.sh --json
|
||||
```
|
||||
|
||||
### 4. 删除实例
|
||||
|
||||
```bash
|
||||
./remove-instance.sh --instance-id demo-001
|
||||
```
|
||||
|
||||
如果要把实例目录也一并清掉:
|
||||
|
||||
```bash
|
||||
./remove-instance.sh --instance-id demo-001 --purge-data
|
||||
```
|
||||
|
||||
## 目录约定
|
||||
|
||||
默认实例数据目录:
|
||||
默认数据目录:
|
||||
|
||||
```text
|
||||
runtime/instances/<instance-slug>/
|
||||
runtime/standalone/beaver-home/
|
||||
```
|
||||
|
||||
其中会生成:
|
||||
|
||||
```text
|
||||
runtime/instances/<instance-slug>/
|
||||
└── beaver-home
|
||||
├── config.json
|
||||
├── web_auth_users.json
|
||||
└── workspace/
|
||||
```
|
||||
|
||||
这个目录是单用户 sandbox 的配置与数据边界。容器内会把它挂到:
|
||||
容器内挂载到:
|
||||
|
||||
```text
|
||||
/root/.beaver/
|
||||
@ -127,36 +63,4 @@ BEAVER_CONFIG_PATH=/root/.beaver/config.json
|
||||
BEAVER_WORKSPACE=/root/.beaver/workspace
|
||||
```
|
||||
|
||||
所以模型 `provider/api_key/api_base/model` 配一次即可,Web / channel 请求不需要、也不应该携带 API Key。
|
||||
|
||||
`create-instance.sh` 默认会把仓库根目录的 `skills/` 非覆盖式复制到实例 workspace,并把同一个目录只读挂载到实例容器的 `/opt/app/initial-skills`。`entrypoint.sh` 每次启动都会用该目录补齐缺失的 published 初始 skills;已有 skill 目录不会被覆盖,index 只做并集追加。
|
||||
|
||||
## 当前状态
|
||||
|
||||
这层已经支持:
|
||||
|
||||
- 统一镜像构建
|
||||
- 镜像内安装并启动新的 `beaver` 后端
|
||||
- 实例创建
|
||||
- 实例删除
|
||||
- 实例列表
|
||||
- 基于注册表的端口分配
|
||||
- 为 deploy-control / router-proxy 记录用户名和实例 host
|
||||
|
||||
## 生产注意
|
||||
|
||||
- 实例容器的宿主机端口默认只绑定 `127.0.0.1`
|
||||
- 外部访问应统一走 `router-proxy`
|
||||
- 如果你确实要把单个实例端口直接暴露到公网,再显式传 `--host-bind-ip 0.0.0.0`
|
||||
- 使用共享 `external-connector` sidecar 时,每个实例容器都必须带自己的内部回调地址:
|
||||
`EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=http://<app-instance-container-name>:8080`
|
||||
- 通过 `create-instance.sh --network <docker-network>` 创建实例时,脚本会默认使用
|
||||
`http://<container-name>:8080` 作为回调地址;生产部署也可以用
|
||||
`--external-connector-callback-base-url <url>` 显式覆盖
|
||||
- `BEAVER_BRIDGE_BASE_URL` 只作为 sidecar 的旧连接或兜底地址;多实例部署不能依赖它路由所有入站事件
|
||||
|
||||
下一步可以继续接:
|
||||
|
||||
- portal 调用创建实例
|
||||
- URL 分配和反向代理
|
||||
- 实例续期 / 停用 / 启用
|
||||
模型 provider、API key、workspace 数据都在这个目录下维护。
|
||||
|
||||
@ -7,7 +7,6 @@ import asyncio
|
||||
import io
|
||||
import mimetypes
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import time
|
||||
import zipfile
|
||||
@ -17,6 +16,8 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from beaver.engine.providers.registry import PROVIDERS, find_by_name
|
||||
from beaver.foundation.config import default_config_path, load_config
|
||||
from beaver.foundation.events import ChannelIdentity, InboundMessage
|
||||
@ -69,6 +70,12 @@ from .files import (
|
||||
workspace_file_preview,
|
||||
workspace_file_path,
|
||||
)
|
||||
from .keycloak_auth import (
|
||||
KeycloakAuthConfig,
|
||||
KeycloakIdentity,
|
||||
KeycloakTokenVerifier,
|
||||
extract_bearer_token,
|
||||
)
|
||||
from .schemas import (
|
||||
WebChatAcceptanceRequest,
|
||||
WebChatAcceptanceResponse,
|
||||
@ -556,17 +563,22 @@ def create_app(
|
||||
shutdown_force=shutdown_force,
|
||||
),
|
||||
)
|
||||
app.state.auth_tokens = {}
|
||||
app.state.handoff_codes = {}
|
||||
app.state.auth_file = Path(os.getenv("BEAVER_AUTH_FILE") or "")
|
||||
app.state.keycloak_auth_config = KeycloakAuthConfig.from_env()
|
||||
app.state.keycloak_token_verifier = KeycloakTokenVerifier(config=app.state.keycloak_auth_config)
|
||||
max_file_size = 50 * 1024 * 1024
|
||||
max_user_file_upload_size = _int_env("BEAVER_USER_FILES_MAX_UPLOAD_BYTES", 5 * 1024 * 1024 * 1024)
|
||||
user_file_upload_part_size = _int_env("BEAVER_USER_FILES_UPLOAD_PART_SIZE", 10 * 1024 * 1024)
|
||||
|
||||
def _user_file_resolver(request: Request, authorization: str | None) -> UserFileStorageResolver:
|
||||
username = _require_web_user(app, authorization)
|
||||
identity = _require_web_identity(app, authorization)
|
||||
loaded = get_agent_service(request).create_loop().boot()
|
||||
auth_context = build_file_auth_context(username=username, config=loaded.config)
|
||||
auth_context = build_file_auth_context(
|
||||
username=identity.username,
|
||||
config=loaded.config,
|
||||
user_id=identity.user_id,
|
||||
scopes=identity.realm_roles + identity.client_roles,
|
||||
auth_source="keycloak",
|
||||
)
|
||||
return UserFileStorageResolver(config=loaded.config, workspace=loaded.workspace, auth_context=auth_context)
|
||||
|
||||
async def _user_file_service(request: Request, authorization: str | None) -> UserFileService:
|
||||
@ -970,168 +982,72 @@ def create_app(
|
||||
_schedule_self_restart()
|
||||
return JSONResponse({"ok": True, "restarting": True}, status_code=202)
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_login(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
username = _clean_text(payload.get("username"))
|
||||
password = str(payload.get("password") or "")
|
||||
if not username or not password:
|
||||
raise HTTPException(status_code=400, detail="Username and password are required")
|
||||
@app.post("/api/auth/callback")
|
||||
async def auth_callback(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
code = _clean_text(payload.get("code"))
|
||||
code_verifier = _clean_text(payload.get("code_verifier"))
|
||||
redirect_uri = _clean_text(payload.get("redirect_uri"))
|
||||
nonce = _clean_text(payload.get("nonce")) or None
|
||||
if not code or not code_verifier or not redirect_uri:
|
||||
raise HTTPException(status_code=400, detail="code, code_verifier, and redirect_uri are required")
|
||||
|
||||
users = _load_auth_users(_auth_file_path())
|
||||
expected = users.get(username)
|
||||
if expected is None or not secrets.compare_digest(expected, password):
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
keycloak_config: KeycloakAuthConfig = app.state.keycloak_auth_config
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0, trust_env=False) as client:
|
||||
response = await client.post(
|
||||
keycloak_config.token_url,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": keycloak_config.client_id,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Keycloak token exchange failed: {exc}") from exc
|
||||
if response.is_error:
|
||||
raise HTTPException(status_code=401, detail=f"Keycloak token exchange rejected: {response.text}")
|
||||
token_payload = response.json()
|
||||
if not isinstance(token_payload, dict):
|
||||
raise HTTPException(status_code=502, detail="Invalid Keycloak token response")
|
||||
access_token = _clean_text(token_payload.get("access_token"))
|
||||
id_token = _clean_text(token_payload.get("id_token"))
|
||||
refresh_token = _clean_text(token_payload.get("refresh_token"))
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=502, detail="Keycloak token response missing access_token")
|
||||
|
||||
token = _issue_web_token(app, username)
|
||||
handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token)
|
||||
verifier: KeycloakTokenVerifier = app.state.keycloak_token_verifier
|
||||
identity = verifier.verify(id_token, expected_nonce=nonce) if id_token else verifier.verify(access_token)
|
||||
verifier.verify(access_token)
|
||||
return {
|
||||
"access_token": token,
|
||||
"refresh_token": "",
|
||||
"token_type": "bearer",
|
||||
"user_id": username,
|
||||
"username": username,
|
||||
"access_token": access_token,
|
||||
"id_token": id_token,
|
||||
"refresh_token": refresh_token,
|
||||
"expires_in": token_payload.get("expires_in"),
|
||||
"token_type": token_payload.get("token_type") or "bearer",
|
||||
"user_id": identity.user_id,
|
||||
"username": identity.username,
|
||||
"email": identity.email,
|
||||
"role": "owner",
|
||||
"handoff_code": handoff_code,
|
||||
"handoff_expires_at": handoff_expires_at,
|
||||
"backend_connection": _backend_connection_view(request),
|
||||
"local_backend": _local_backend_view(),
|
||||
}
|
||||
|
||||
@app.post("/api/auth/register")
|
||||
async def auth_register(request: Request, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
username = _clean_text(payload.get("username"))
|
||||
password = str(payload.get("password") or "")
|
||||
email = _clean_text(payload.get("email")) or ""
|
||||
if not username or not password:
|
||||
raise HTTPException(status_code=400, detail="Username and password are required")
|
||||
|
||||
auth_file = _auth_file_path()
|
||||
users = _load_auth_users_if_present(auth_file)
|
||||
user_exists = username in users
|
||||
if user_exists and not secrets.compare_digest(users[username], password):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Username already exists. Use the existing password to finish setup or log in.",
|
||||
)
|
||||
|
||||
agent_service = get_agent_service(request)
|
||||
loaded = agent_service.create_loop().boot()
|
||||
config = loaded.config
|
||||
authz_base_url = _clean_text(payload.get("authz_base_url")) or (config.authz.base_url if config.authz.enabled else "")
|
||||
backend_name = _clean_text(payload.get("backend_name")) or config.backend_identity.name or username
|
||||
requested_backend_id = _clean_text(payload.get("backend_id")) or config.backend_identity.backend_id or None
|
||||
public_base_url = (
|
||||
_clean_text(payload.get("base_url"))
|
||||
or config.backend_identity.public_base_url
|
||||
or os.getenv("BEAVER_FRONTEND_PUBLIC_BASE_URL")
|
||||
or str(request.base_url).rstrip("/")
|
||||
)
|
||||
frontend_base_url = _clean_text(payload.get("frontend_base_url")) or public_base_url
|
||||
|
||||
authz_user_registered = False
|
||||
authz_backend_registered = False
|
||||
local_backend: dict[str, Any] | None = None
|
||||
|
||||
if authz_base_url:
|
||||
from beaver.integrations.authz import AuthzClient
|
||||
|
||||
try:
|
||||
authz_payload = await AuthzClient(
|
||||
authz_base_url,
|
||||
timeout_seconds=config.authz.request_timeout_seconds,
|
||||
).register_user(
|
||||
username=username,
|
||||
password=password,
|
||||
email=email or None,
|
||||
backend_name=backend_name,
|
||||
backend_id=requested_backend_id,
|
||||
base_url=public_base_url,
|
||||
frontend_base_url=frontend_base_url,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - expose upstream setup failures to portal
|
||||
raise HTTPException(status_code=502, detail=f"AuthZ registration failed: {exc}") from exc
|
||||
|
||||
backend = authz_payload.get("backend") if isinstance(authz_payload, dict) else {}
|
||||
if isinstance(backend, dict):
|
||||
backend_id = _clean_text(backend.get("backend_id")) or requested_backend_id
|
||||
client_id = _clean_text(backend.get("client_id")) or backend_id
|
||||
client_secret = _clean_text(backend.get("client_secret")) or config.backend_identity.client_secret
|
||||
if backend_id and client_id and client_secret:
|
||||
local_backend = _save_backend_identity(
|
||||
agent_service,
|
||||
config_path=config.config_path or default_config_path(workspace=loaded.workspace),
|
||||
backend_id=backend_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
name=_clean_text(backend.get("name")) or backend_name,
|
||||
public_base_url=public_base_url,
|
||||
authz_base_url=authz_base_url,
|
||||
)
|
||||
authz_backend_registered = True
|
||||
authz_user_registered = bool(authz_payload)
|
||||
|
||||
if not user_exists:
|
||||
users[username] = password
|
||||
_save_auth_users(auth_file, users)
|
||||
|
||||
token = _issue_web_token(app, username)
|
||||
handoff_code, handoff_expires_at = _issue_handoff_code(app, username, token)
|
||||
backend_connection = {
|
||||
**_backend_connection_view(request),
|
||||
"public_base_url": public_base_url,
|
||||
"api_base_url": public_base_url,
|
||||
"frontend_base_url": frontend_base_url,
|
||||
"registered": bool(local_backend),
|
||||
}
|
||||
if local_backend is not None:
|
||||
backend_connection.update(
|
||||
{
|
||||
"backend_id": local_backend.get("backend_id"),
|
||||
"client_id": local_backend.get("client_id"),
|
||||
"name": local_backend.get("name"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"refresh_token": "",
|
||||
"token_type": "bearer",
|
||||
"user_id": username,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"role": "owner",
|
||||
"handoff_code": handoff_code,
|
||||
"handoff_expires_at": handoff_expires_at,
|
||||
"existing_user": user_exists,
|
||||
"authz": {
|
||||
"enabled": bool(authz_base_url),
|
||||
"base_url": authz_base_url or None,
|
||||
"user_registered": authz_user_registered,
|
||||
"backend_registered": authz_backend_registered,
|
||||
},
|
||||
"backend_connection": backend_connection,
|
||||
"local_backend": local_backend or _local_backend_view(),
|
||||
}
|
||||
|
||||
@app.post("/api/auth/handoff/consume")
|
||||
async def auth_handoff_consume(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return _consume_handoff_code(app, str(payload.get("code") or ""))
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def auth_me(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
username = _require_web_user(app, authorization)
|
||||
identity = _require_web_identity(app, authorization)
|
||||
return {
|
||||
"id": username,
|
||||
"username": username,
|
||||
"email": os.getenv("BEAVER_BACKEND_IDENTITY__EMAIL", ""),
|
||||
"id": identity.user_id,
|
||||
"username": identity.username,
|
||||
"email": identity.email,
|
||||
"role": "owner",
|
||||
"quota_tier": "single-user",
|
||||
}
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, Any]:
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
token = authorization[7:].strip()
|
||||
app.state.auth_tokens.pop(token, None)
|
||||
async def auth_logout() -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/api/providers/{provider_name}/config", response_model=WebProviderConfigResponse)
|
||||
@ -3288,82 +3204,6 @@ def _provider_enabled(provider_name: str, provider_cfg: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _auth_file_path() -> Path:
|
||||
raw = os.getenv("BEAVER_AUTH_FILE")
|
||||
if raw:
|
||||
return Path(raw)
|
||||
return Path.home() / ".beaver" / "web_auth_users.json"
|
||||
|
||||
|
||||
def _load_auth_users(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=500, detail=f"Auth file not found: {path}")
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Invalid auth file: {path}") from exc
|
||||
|
||||
users: dict[str, str] = {}
|
||||
if isinstance(raw, dict):
|
||||
entries = raw.get("users") or raw.get("accounts")
|
||||
if isinstance(entries, list):
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
username = _clean_text(entry.get("username"))
|
||||
password = entry.get("password")
|
||||
if username and isinstance(password, str):
|
||||
users[username] = password
|
||||
for key, value in raw.items():
|
||||
if key in {"users", "accounts"}:
|
||||
continue
|
||||
username = _clean_text(key)
|
||||
if username and isinstance(value, str):
|
||||
users[username] = value
|
||||
if not users:
|
||||
raise HTTPException(status_code=500, detail=f"No valid users found in auth file: {path}")
|
||||
return users
|
||||
|
||||
|
||||
def _load_auth_users_if_present(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return _load_auth_users(path)
|
||||
|
||||
|
||||
def _save_auth_users(path: Path, users: dict[str, str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"users": [
|
||||
{"username": username, "password": password}
|
||||
for username, password in sorted(users.items())
|
||||
]
|
||||
}
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _issue_web_token(app: FastAPI, username: str) -> str:
|
||||
token = secrets.token_urlsafe(32)
|
||||
app.state.auth_tokens[token] = username
|
||||
return token
|
||||
|
||||
|
||||
def _handoff_ttl_seconds() -> int:
|
||||
raw = os.getenv("BEAVER_HANDOFF_CODE_TTL_SECONDS", "90").strip()
|
||||
try:
|
||||
return max(15, int(raw))
|
||||
except ValueError:
|
||||
return 90
|
||||
|
||||
|
||||
def _handoff_replay_window_seconds() -> int:
|
||||
raw = os.getenv("BEAVER_HANDOFF_REPLAY_WINDOW_SECONDS", "15").strip()
|
||||
try:
|
||||
return max(1, int(raw))
|
||||
except ValueError:
|
||||
return 15
|
||||
|
||||
|
||||
def _int_env(name: str, default: int) -> int:
|
||||
raw = os.getenv(name, "").strip()
|
||||
if not raw:
|
||||
@ -3385,81 +3225,10 @@ def _human_upload_size(size: int) -> str:
|
||||
return f"{size}B"
|
||||
|
||||
|
||||
def _prune_handoff_codes(app: FastAPI) -> None:
|
||||
now = time.time()
|
||||
replay_window = _handoff_replay_window_seconds()
|
||||
expired = []
|
||||
for code, payload in list(app.state.handoff_codes.items()):
|
||||
expires_at = float(payload.get("expires_at") or 0)
|
||||
consumed_at = payload.get("consumed_at")
|
||||
if expires_at <= now:
|
||||
expired.append(code)
|
||||
elif consumed_at is not None and now - float(consumed_at) > replay_window:
|
||||
expired.append(code)
|
||||
for code in expired:
|
||||
app.state.handoff_codes.pop(code, None)
|
||||
|
||||
|
||||
def _issue_handoff_code(app: FastAPI, username: str, access_token: str, refresh_token: str = "") -> tuple[str, int]:
|
||||
_prune_handoff_codes(app)
|
||||
code = secrets.token_urlsafe(24)
|
||||
expires_at = int(time.time()) + _handoff_ttl_seconds()
|
||||
app.state.handoff_codes[code] = {
|
||||
"username": username,
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"expires_at": expires_at,
|
||||
"consumed_at": None,
|
||||
}
|
||||
return code, expires_at
|
||||
|
||||
|
||||
def _consume_handoff_code(app: FastAPI, code: str) -> dict[str, Any]:
|
||||
if not code.strip():
|
||||
raise HTTPException(status_code=400, detail="Handoff code is required")
|
||||
_prune_handoff_codes(app)
|
||||
payload = app.state.handoff_codes.get(code)
|
||||
if payload is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired handoff code")
|
||||
now = time.time()
|
||||
expires_at = float(payload.get("expires_at") or 0)
|
||||
if expires_at <= now:
|
||||
app.state.handoff_codes.pop(code, None)
|
||||
raise HTTPException(status_code=410, detail="Handoff code expired")
|
||||
consumed_at = payload.get("consumed_at")
|
||||
if consumed_at is None:
|
||||
payload["consumed_at"] = now
|
||||
elif now - float(consumed_at) > _handoff_replay_window_seconds():
|
||||
app.state.handoff_codes.pop(code, None)
|
||||
raise HTTPException(status_code=410, detail="Handoff code already used")
|
||||
username = str(payload.get("username") or "").strip()
|
||||
access_token = str(payload.get("access_token") or "").strip()
|
||||
if not username or not access_token:
|
||||
app.state.handoff_codes.pop(code, None)
|
||||
raise HTTPException(status_code=401, detail="Invalid handoff payload")
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": str(payload.get("refresh_token") or ""),
|
||||
"token_type": "bearer",
|
||||
"user_id": username,
|
||||
"username": username,
|
||||
"role": "owner",
|
||||
}
|
||||
|
||||
|
||||
def _require_web_user(app: FastAPI, authorization: str | None) -> str:
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Missing Authorization header")
|
||||
prefix = "bearer "
|
||||
if not authorization.lower().startswith(prefix):
|
||||
raise HTTPException(status_code=401, detail="Invalid Authorization header")
|
||||
token = authorization[len(prefix):].strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
username = app.state.auth_tokens.get(token)
|
||||
if not username:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
return username
|
||||
def _require_web_identity(app: FastAPI, authorization: str | None) -> KeycloakIdentity:
|
||||
token = extract_bearer_token(authorization)
|
||||
verifier: KeycloakTokenVerifier = app.state.keycloak_token_verifier
|
||||
return verifier.verify(token)
|
||||
|
||||
|
||||
def _backend_connection_view(request: Request) -> dict[str, Any]:
|
||||
|
||||
152
app-instance/backend/beaver/interfaces/web/keycloak_auth.py
Normal file
152
app-instance/backend/beaver/interfaces/web/keycloak_auth.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""Keycloak OIDC token verification for the Beaver web app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
|
||||
try:
|
||||
from fastapi import HTTPException
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
class HTTPException(Exception): # type: ignore[override]
|
||||
def __init__(self, status_code: int, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
DEFAULT_KEYCLOAK_ISSUER = "https://keycloak.bwgdi.com/realms/beaver"
|
||||
DEFAULT_KEYCLOAK_CLIENT_ID = "beaver-agnet"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeycloakAuthConfig:
|
||||
issuer: str
|
||||
client_id: str
|
||||
token_url: str
|
||||
jwks_url: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "KeycloakAuthConfig":
|
||||
issuer = _clean_base_url(os.getenv("BEAVER_KEYCLOAK_ISSUER") or DEFAULT_KEYCLOAK_ISSUER)
|
||||
client_id = (os.getenv("BEAVER_KEYCLOAK_CLIENT_ID") or DEFAULT_KEYCLOAK_CLIENT_ID).strip()
|
||||
token_url = (
|
||||
os.getenv("BEAVER_KEYCLOAK_TOKEN_URL", "").strip()
|
||||
or f"{issuer}/protocol/openid-connect/token"
|
||||
)
|
||||
jwks_url = (
|
||||
os.getenv("BEAVER_KEYCLOAK_JWKS_URL", "").strip()
|
||||
or f"{issuer}/protocol/openid-connect/certs"
|
||||
)
|
||||
return cls(issuer=issuer, client_id=client_id, token_url=token_url, jwks_url=jwks_url)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeycloakIdentity:
|
||||
user_id: str
|
||||
username: str
|
||||
email: str = ""
|
||||
name: str = ""
|
||||
realm_roles: tuple[str, ...] = ()
|
||||
client_roles: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def extract_bearer_token(authorization: str | None) -> str:
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Missing Authorization header")
|
||||
prefix = "bearer "
|
||||
if not authorization.lower().startswith(prefix):
|
||||
raise HTTPException(status_code=401, detail="Invalid Authorization header")
|
||||
token = authorization[len(prefix):].strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return token
|
||||
|
||||
|
||||
class KeycloakTokenVerifier:
|
||||
def __init__(self, *, config: KeycloakAuthConfig) -> None:
|
||||
self.config = config
|
||||
self._jwks_client = PyJWKClient(config.jwks_url)
|
||||
|
||||
def verify(self, token: str, *, expected_nonce: str | None = None) -> KeycloakIdentity:
|
||||
try:
|
||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token).key
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
issuer=self.config.issuer,
|
||||
options={
|
||||
"require": ["exp", "iat", "iss"],
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - normalize JWT/JWKS failures for HTTP callers
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") from exc
|
||||
return self.validate_claims(claims, expected_nonce=expected_nonce)
|
||||
|
||||
def validate_claims(self, claims: dict[str, Any], *, expected_nonce: str | None = None) -> KeycloakIdentity:
|
||||
now = int(time.time())
|
||||
issuer = str(claims.get("iss") or "")
|
||||
if issuer != self.config.issuer:
|
||||
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||
exp = _int_claim(claims, "exp")
|
||||
iat = _int_claim(claims, "iat")
|
||||
if exp <= now:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
if iat > now + 120:
|
||||
raise HTTPException(status_code=401, detail="Token issued in the future")
|
||||
if not _matches_client(claims.get("aud"), self.config.client_id) and claims.get("azp") != self.config.client_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||
if expected_nonce is not None and claims.get("nonce") != expected_nonce:
|
||||
raise HTTPException(status_code=401, detail="Invalid token nonce")
|
||||
|
||||
user_id = str(claims.get("sub") or "").strip()
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Token subject is required")
|
||||
username = (
|
||||
str(claims.get("preferred_username") or "").strip()
|
||||
or str(claims.get("email") or "").strip()
|
||||
or user_id
|
||||
)
|
||||
return KeycloakIdentity(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
email=str(claims.get("email") or "").strip(),
|
||||
name=str(claims.get("name") or "").strip(),
|
||||
realm_roles=_roles_from(claims.get("realm_access")),
|
||||
client_roles=_roles_from((claims.get("resource_access") or {}).get(self.config.client_id) if isinstance(claims.get("resource_access"), dict) else None),
|
||||
)
|
||||
|
||||
|
||||
def _clean_base_url(value: str) -> str:
|
||||
return value.strip().rstrip("/")
|
||||
|
||||
|
||||
def _int_claim(claims: dict[str, Any], key: str) -> int:
|
||||
try:
|
||||
return int(claims[key])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=401, detail=f"Token {key} claim is required") from exc
|
||||
|
||||
|
||||
def _matches_client(audience: Any, client_id: str) -> bool:
|
||||
if isinstance(audience, str):
|
||||
return audience == client_id
|
||||
if isinstance(audience, list):
|
||||
return client_id in {str(item) for item in audience}
|
||||
return False
|
||||
|
||||
|
||||
def _roles_from(value: Any) -> tuple[str, ...]:
|
||||
if not isinstance(value, dict):
|
||||
return ()
|
||||
roles = value.get("roles")
|
||||
if not isinstance(roles, list):
|
||||
return ()
|
||||
return tuple(str(role) for role in roles if str(role).strip())
|
||||
@ -886,11 +886,9 @@ tool_hints:
|
||||
<div class="panel">
|
||||
<h3>Beaver 平台部署层</h3>
|
||||
<ul>
|
||||
<li><span class="code">auth-portal</span>:用户入口、登录注册。</li>
|
||||
<li><span class="code">authz-service</span>:授权服务和后端身份。</li>
|
||||
<li><span class="code">deploy-control</span>:创建和管理单用户实例。</li>
|
||||
<li><span class="code">router-proxy</span>:按 Host 转发到实例。</li>
|
||||
<li>每个用户一个 <span class="code">app-instance</span>,Docker 容器隔离 + per-instance workspace。</li>
|
||||
<li><span class="code">Keycloak OIDC</span>:用户入口、SSO 登录与登出。</li>
|
||||
<li><span class="code">app-instance</span>:单体应用容器,内置前端、后端和 Nginx。</li>
|
||||
<li>外部编排系统负责部署和访问入口,当前应用不维护实例注册表。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel">
|
||||
|
||||
@ -13,6 +13,7 @@ dependencies = [
|
||||
"litellm>=1.79.0,<2.0.0",
|
||||
"minio>=7.2.0,<8.0.0",
|
||||
"openai>=1.79.0,<2.0.0",
|
||||
"PyJWT[crypto]>=2.9.0,<3.0.0",
|
||||
"pydantic>=2.12.0,<3.0.0",
|
||||
"python-multipart>=0.0.20,<1.0.0",
|
||||
"typer>=0.20.0,<1.0.0",
|
||||
|
||||
110
app-instance/backend/tests/unit/test_keycloak_auth.py
Normal file
110
app-instance/backend/tests/unit/test_keycloak_auth.py
Normal file
@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from beaver.interfaces.web.keycloak_auth import (
|
||||
KeycloakAuthConfig,
|
||||
KeycloakIdentity,
|
||||
KeycloakTokenVerifier,
|
||||
extract_bearer_token,
|
||||
)
|
||||
|
||||
|
||||
def _verifier() -> KeycloakTokenVerifier:
|
||||
return KeycloakTokenVerifier(
|
||||
config=KeycloakAuthConfig(
|
||||
issuer="https://keycloak.bwgdi.com/realms/beaver",
|
||||
client_id="beaver-agnet",
|
||||
token_url="https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/token",
|
||||
jwks_url="https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/certs",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _claims(**overrides):
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"name": "Alice Example",
|
||||
"iss": "https://keycloak.bwgdi.com/realms/beaver",
|
||||
"aud": "beaver-agnet",
|
||||
"azp": "beaver-agnet",
|
||||
"iat": now,
|
||||
"exp": now + 300,
|
||||
"nonce": "nonce-1",
|
||||
"realm_access": {"roles": ["user", "admin"]},
|
||||
"resource_access": {"beaver-agnet": {"roles": ["agent-user"]}},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_extract_bearer_token_accepts_case_insensitive_prefix() -> None:
|
||||
assert extract_bearer_token("Bearer abc.def") == "abc.def"
|
||||
assert extract_bearer_token("bearer xyz") == "xyz"
|
||||
|
||||
|
||||
def test_extract_bearer_token_rejects_missing_or_invalid_header() -> None:
|
||||
with pytest.raises(HTTPException) as missing:
|
||||
extract_bearer_token(None)
|
||||
with pytest.raises(HTTPException) as invalid:
|
||||
extract_bearer_token("Basic abc")
|
||||
|
||||
assert missing.value.status_code == 401
|
||||
assert invalid.value.status_code == 401
|
||||
|
||||
|
||||
def test_validate_claims_accepts_audience_and_extracts_roles() -> None:
|
||||
identity = _verifier().validate_claims(_claims(), expected_nonce="nonce-1")
|
||||
|
||||
assert identity == KeycloakIdentity(
|
||||
user_id="user-123",
|
||||
username="alice",
|
||||
email="alice@example.com",
|
||||
name="Alice Example",
|
||||
realm_roles=("user", "admin"),
|
||||
client_roles=("agent-user",),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_claims_accepts_azp_when_audience_differs() -> None:
|
||||
identity = _verifier().validate_claims(_claims(aud="account", azp="beaver-agnet"))
|
||||
|
||||
assert identity.user_id == "user-123"
|
||||
|
||||
|
||||
def test_validate_claims_rejects_wrong_nonce() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_verifier().validate_claims(_claims(), expected_nonce="different")
|
||||
|
||||
assert exc.value.status_code == 401
|
||||
assert "nonce" in exc.value.detail.lower()
|
||||
|
||||
|
||||
def test_validate_claims_rejects_wrong_audience_and_azp() -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_verifier().validate_claims(_claims(aud="account", azp="other-client"))
|
||||
|
||||
assert exc.value.status_code == 401
|
||||
assert "audience" in exc.value.detail.lower()
|
||||
|
||||
|
||||
def test_verify_raises_http_exception_for_bad_jwt(monkeypatch) -> None:
|
||||
verifier = _verifier()
|
||||
|
||||
def fake_decode(*args, **kwargs):
|
||||
raise jwt.InvalidTokenError("bad token")
|
||||
|
||||
monkeypatch.setattr(jwt, "decode", fake_decode)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verifier.verify("bad-token")
|
||||
|
||||
assert exc.value.status_code == 401
|
||||
assert "invalid token" in exc.value.detail.lower()
|
||||
@ -5,6 +5,7 @@ from pathlib import Path
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from beaver.interfaces.web.app import create_app
|
||||
from beaver.interfaces.web.keycloak_auth import KeycloakIdentity
|
||||
from beaver.services.agent_service import AgentService
|
||||
from beaver.services.user_file_resolver import UserFileStorageResolver
|
||||
from beaver.services.user_files import LocalUserFileStorage, UserFileService
|
||||
@ -12,10 +13,24 @@ from beaver.services.user_files import LocalUserFileStorage, UserFileService
|
||||
|
||||
def _auth_headers(app, username: str = "alice") -> dict[str, str]:
|
||||
token = f"test-token-{username}"
|
||||
app.state.auth_tokens[token] = username
|
||||
app.state.keycloak_token_verifier = _FakeKeycloakVerifier(username=username)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
class _FakeKeycloakVerifier:
|
||||
def __init__(self, *, username: str) -> None:
|
||||
self.username = username
|
||||
|
||||
def verify(self, token: str, *, expected_nonce: str | None = None) -> KeycloakIdentity:
|
||||
return KeycloakIdentity(
|
||||
user_id=self.username,
|
||||
username=self.username,
|
||||
email=f"{self.username}@example.com",
|
||||
realm_roles=("user",),
|
||||
client_roles=("agent-user",),
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_browser_api_manages_workspace_files(tmp_path: Path) -> None:
|
||||
service = AgentService(workspace=tmp_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
|
||||
2
app-instance/backend/uv.lock
generated
2
app-instance/backend/uv.lock
generated
@ -290,6 +290,7 @@ dependencies = [
|
||||
{ name = "minio" },
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
@ -335,6 +336,7 @@ requires-dist = [
|
||||
{ name = "minio", specifier = ">=7.2.0,<8.0.0" },
|
||||
{ name = "openai", specifier = ">=1.79.0,<2.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.9.0,<3.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.0.0,<2.0.0" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.20,<1.0.0" },
|
||||
|
||||
@ -1,861 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_TOOL="${SCRIPT_DIR}/instance-registry.py"
|
||||
|
||||
IMAGE_NAME="${IMAGE_NAME:-beaver/app-instance:latest}"
|
||||
INSTANCES_ROOT_DEFAULT="${SCRIPT_DIR}/runtime/instances"
|
||||
REGISTRY_PATH_DEFAULT="${SCRIPT_DIR}/runtime/registry/instances.json"
|
||||
KNOWN_PROVIDERS=" custom anthropic openai openrouter deepseek groq zhipu dashscope vllm gemini moonshot minimax aihubmix siliconflow volcengine "
|
||||
|
||||
INSTANCE_ID=""
|
||||
INSTANCE_SLUG=""
|
||||
CONTAINER_NAME=""
|
||||
HOST_PORT=""
|
||||
PUBLIC_URL=""
|
||||
AUTHZ_BASE_URL=""
|
||||
AUTHZ_INTERNAL_TOKEN=""
|
||||
AUTHZ_OUTLOOK_MCP_URL=""
|
||||
OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}"
|
||||
OUTLOOK_MCP_CALL_TIMEOUT_SECONDS="${OUTLOOK_MCP_CALL_TIMEOUT_SECONDS:-60}"
|
||||
USER_FILES_MAX_UPLOAD_BYTES="${USER_FILES_MAX_UPLOAD_BYTES:-}"
|
||||
EXTERNAL_CONNECTOR_BASE_URL="${EXTERNAL_CONNECTOR_BASE_URL:-http://external-connector:8787}"
|
||||
EXTERNAL_CONNECTOR_TOKEN="${EXTERNAL_CONNECTOR_TOKEN:-}"
|
||||
BEAVER_BRIDGE_TOKEN="${BEAVER_BRIDGE_TOKEN:-}"
|
||||
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="${EXTERNAL_CONNECTOR_CALLBACK_BASE_URL:-}"
|
||||
BACKEND_ID=""
|
||||
CLIENT_ID=""
|
||||
CLIENT_SECRET=""
|
||||
BACKEND_NAME=""
|
||||
MODEL="openai/gpt-5"
|
||||
PROVIDER="openai"
|
||||
API_KEY="${API_KEY:-}"
|
||||
API_BASE="${API_BASE:-}"
|
||||
SKIP_PROVIDER_CONFIG=0
|
||||
AUTH_USERNAME=""
|
||||
AUTH_PASSWORD=""
|
||||
USERNAME=""
|
||||
EMAIL=""
|
||||
INSTANCE_HOST=""
|
||||
AUTH_PORTAL_URL="${AUTH_PORTAL_URL:-}"
|
||||
AUTH_PORTAL_PORT="${AUTH_PORTAL_PORT:-3081}"
|
||||
INSTANCES_ROOT="${INSTANCES_ROOT:-$INSTANCES_ROOT_DEFAULT}"
|
||||
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
||||
NETWORK_NAME="${NETWORK_NAME:-}"
|
||||
HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}"
|
||||
INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}"
|
||||
INITIAL_SKILLS_EXCLUDE="${INITIAL_SKILLS_EXCLUDE:-officebench-mcp}"
|
||||
SEED_INITIAL_SKILLS=1
|
||||
FORCE_BUILD=0
|
||||
REPLACE=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./create-instance.sh --instance-id demo --auth-username admin --auth-password 123456 [options]
|
||||
|
||||
Required:
|
||||
--instance-id <id> Unique instance id.
|
||||
--auth-username <name> Initial web login username.
|
||||
--auth-password <password> Initial web login password.
|
||||
|
||||
Optional:
|
||||
--image <name> Docker image tag. Default: beaver/app-instance:latest
|
||||
--container-name <name> Docker container name. Default: app-instance-<slug>
|
||||
--host-port <port> Host port to publish. Default: auto-pick from 20000-29999.
|
||||
--public-url <url> Public URL exposed to users. Default: http://127.0.0.1:<host-port>
|
||||
--provider <name> Provider key in config.json. Default: openai
|
||||
--api-base <url> Optional custom provider base URL.
|
||||
--api-key <key> Provider API key for Boardware Genius.
|
||||
--model <name> Model name. Default: openai/gpt-5
|
||||
--skip-provider-config Create the instance without model/provider/API key settings.
|
||||
--authz-base-url <url> AuthZ service base URL.
|
||||
--authz-internal-token <token>
|
||||
AuthZ internal token for backend-only user file storage settings lookup.
|
||||
--authz-outlook-mcp-url <url>
|
||||
Managed Outlook MCP URL for AuthZ mode.
|
||||
--outlook-mcp-server-id <id>
|
||||
Default Outlook MCP server id. Default: outlook_mcp
|
||||
--outlook-mcp-call-timeout-seconds <seconds>
|
||||
Backend wait timeout for Outlook MCP calls. Default: 60
|
||||
--user-files-max-upload-bytes <bytes>
|
||||
Optional max upload size for the user file system.
|
||||
--external-connector-base-url <url>
|
||||
External connector sidecar URL. Default: http://external-connector:8787
|
||||
--external-connector-token <token>
|
||||
Service token used for Beaver-to-sidecar requests.
|
||||
--external-connector-callback-base-url <url>
|
||||
Internal URL the sidecar should call back for inbound events.
|
||||
Default: http://<container-name>:8080 when a Docker network is used.
|
||||
--bridge-token <token> Service token accepted from the connector bridge.
|
||||
--backend-id <id> Pre-assigned backend id.
|
||||
--client-id <id> Pre-assigned AuthZ client id.
|
||||
--client-secret <secret> Pre-assigned AuthZ client secret.
|
||||
--backend-name <name> Display name in backend identity.
|
||||
--auth-portal-url <url> Shared auth portal URL used when building the image.
|
||||
--auth-portal-port <port> Fallback auth portal port. Default: 3081
|
||||
--username <name> Registry username owner. Default: auth username
|
||||
--email <value> Registry email owner.
|
||||
--instance-host <host> Public host used by reverse proxy, for registry only.
|
||||
--instances-root <path> Instance data root. Default: ./runtime/instances
|
||||
--registry <path> Registry JSON path. Default: ./runtime/registry/instances.json
|
||||
--network <name> Optional docker network name.
|
||||
--host-bind-ip <ip> Host bind IP for published port. Default: 127.0.0.1
|
||||
--initial-skills-dir <path> Directory copied into workspace/skills on first create.
|
||||
Default: ../skills
|
||||
--skip-initial-skills Do not seed initial workspace skills.
|
||||
--build Force rebuild image before running.
|
||||
--replace Remove existing container with same name before running.
|
||||
--help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[create-instance] %s\n' "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '[create-instance] %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
slugify() {
|
||||
local input="$1"
|
||||
local output
|
||||
output="$(printf '%s' "$input" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//')"
|
||||
if [[ -z "$output" ]]; then
|
||||
die "instance id produced an empty slug"
|
||||
fi
|
||||
printf '%s' "$output"
|
||||
}
|
||||
|
||||
pick_free_port() {
|
||||
local args=(
|
||||
--registry "$REGISTRY_PATH"
|
||||
next-port
|
||||
--start 20000
|
||||
--end 29999
|
||||
)
|
||||
if [[ -n "$INSTANCE_ID" ]]; then
|
||||
args+=(--exclude-instance-id "$INSTANCE_ID")
|
||||
fi
|
||||
"$REGISTRY_TOOL" "${args[@]}"
|
||||
}
|
||||
|
||||
extract_url_host() {
|
||||
local input_url="$1"
|
||||
INPUT_URL="$input_url" python3 - <<'PY'
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
value = os.environ["INPUT_URL"].strip()
|
||||
parts = urlsplit(value)
|
||||
print(parts.hostname or "")
|
||||
PY
|
||||
}
|
||||
|
||||
render_config_json() {
|
||||
local target_path="$1"
|
||||
|
||||
TARGET_PATH="$target_path" \
|
||||
MODEL="$MODEL" \
|
||||
PROVIDER="$PROVIDER" \
|
||||
API_KEY="$API_KEY" \
|
||||
API_BASE="$API_BASE" \
|
||||
SKIP_PROVIDER_CONFIG="$SKIP_PROVIDER_CONFIG" \
|
||||
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
||||
AUTHZ_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \
|
||||
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" \
|
||||
BACKEND_NAME="$BACKEND_NAME" \
|
||||
PUBLIC_URL="$PUBLIC_URL" \
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
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"
|
||||
skip_provider_config = os.environ["SKIP_PROVIDER_CONFIG"].strip() == "1"
|
||||
|
||||
providers = {}
|
||||
agent_defaults = {
|
||||
"workspace": "/root/.beaver/workspace",
|
||||
}
|
||||
if not skip_provider_config:
|
||||
provider_cfg = {"apiKey": os.environ["API_KEY"]}
|
||||
api_base = os.environ["API_BASE"].strip()
|
||||
if api_base:
|
||||
provider_cfg["apiBase"] = api_base
|
||||
providers[provider] = provider_cfg
|
||||
agent_defaults["provider"] = provider
|
||||
agent_defaults["model"] = os.environ["MODEL"]
|
||||
|
||||
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": agent_defaults
|
||||
},
|
||||
"providers": providers,
|
||||
"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": outlook_mcp_url,
|
||||
},
|
||||
"backend_identity": {
|
||||
"backendId": os.environ["BACKEND_ID"].strip(),
|
||||
"clientId": os.environ["CLIENT_ID"].strip(),
|
||||
"clientSecret": os.environ["CLIENT_SECRET"].strip(),
|
||||
"name": os.environ["BACKEND_NAME"].strip(),
|
||||
"publicBaseUrl": os.environ["PUBLIC_URL"].strip(),
|
||||
},
|
||||
"channels": {
|
||||
"telegram-main": {
|
||||
"enabled": False,
|
||||
"kind": "telegram",
|
||||
"mode": "polling",
|
||||
"accountId": "bot-main",
|
||||
"displayName": "Telegram Main",
|
||||
"secrets": {
|
||||
"botToken": "",
|
||||
},
|
||||
"config": {
|
||||
"requireMentionInGroups": True,
|
||||
"maxMessageChars": 4096,
|
||||
},
|
||||
},
|
||||
"feishu-main": {
|
||||
"enabled": False,
|
||||
"kind": "feishu",
|
||||
"mode": "websocket",
|
||||
"accountId": "tenant-main",
|
||||
"displayName": "Feishu Main",
|
||||
"secrets": {
|
||||
"appId": "",
|
||||
"appSecret": "",
|
||||
},
|
||||
"config": {
|
||||
"domain": "feishu",
|
||||
"connectionMode": "websocket",
|
||||
"requireMentionInGroups": True,
|
||||
},
|
||||
},
|
||||
"qqbot-main": {
|
||||
"enabled": False,
|
||||
"kind": "qqbot",
|
||||
"mode": "websocket",
|
||||
"accountId": "qqbot-main",
|
||||
"displayName": "QQ Bot Main",
|
||||
"secrets": {
|
||||
"appId": "",
|
||||
"clientSecret": "",
|
||||
},
|
||||
"config": {
|
||||
"dmPolicy": "open",
|
||||
"groupPolicy": "allowlist",
|
||||
"markdownSupport": False,
|
||||
},
|
||||
},
|
||||
"weixin-main": {
|
||||
"enabled": False,
|
||||
"kind": "weixin",
|
||||
"mode": "polling",
|
||||
"accountId": "wx-main",
|
||||
"displayName": "Weixin Main",
|
||||
"secrets": {
|
||||
"token": "",
|
||||
},
|
||||
"config": {
|
||||
"dmPolicy": "open",
|
||||
"groupPolicy": "disabled",
|
||||
"textBatchDelaySeconds": 0.5,
|
||||
},
|
||||
},
|
||||
"terminal-dev": {
|
||||
"enabled": True,
|
||||
"kind": "terminal",
|
||||
"mode": "websocket",
|
||||
"accountId": "local",
|
||||
"displayName": "Terminal Dev",
|
||||
"config": {},
|
||||
"secrets": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
render_auth_users_json() {
|
||||
local target_path="$1"
|
||||
|
||||
TARGET_PATH="$target_path" \
|
||||
AUTH_USERNAME="$AUTH_USERNAME" \
|
||||
AUTH_PASSWORD="$AUTH_PASSWORD" \
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
target = Path(os.environ["TARGET_PATH"])
|
||||
data = {
|
||||
"users": [
|
||||
{
|
||||
"username": os.environ["AUTH_USERNAME"],
|
||||
"password": os.environ["AUTH_PASSWORD"],
|
||||
}
|
||||
]
|
||||
}
|
||||
target.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
seed_initial_skills() {
|
||||
local workspace_path="$1"
|
||||
local initial_skills_dir="$2"
|
||||
local target_dir="${workspace_path}/skills"
|
||||
|
||||
if [[ "$SEED_INITIAL_SKILLS" -ne 1 ]]; then
|
||||
return
|
||||
fi
|
||||
if [[ ! -d "$initial_skills_dir" ]]; then
|
||||
log "initial skills directory not found, skipping: ${initial_skills_dir}"
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$target_dir"
|
||||
INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" INITIAL_SKILLS_EXCLUDE="$INITIAL_SKILLS_EXCLUDE" python3 - <<'PY'
|
||||
import json
|
||||
import shutil
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve()
|
||||
target = Path(os.environ["TARGET_DIR"]).resolve()
|
||||
excluded = {item.strip() for item in os.environ.get("INITIAL_SKILLS_EXCLUDE", "").split(",") if item.strip()}
|
||||
|
||||
for child in sorted(initial.iterdir()):
|
||||
if child.name.startswith("."):
|
||||
continue
|
||||
if child.name in excluded:
|
||||
continue
|
||||
destination = target / child.name
|
||||
if destination.exists():
|
||||
continue
|
||||
if child.is_dir():
|
||||
shutil.copytree(child, destination)
|
||||
elif child.is_file():
|
||||
shutil.copy2(child, destination)
|
||||
|
||||
for index_name in ("published", "disabled"):
|
||||
initial_index = initial / "_index" / f"{index_name}.json"
|
||||
target_index = target / "_index" / f"{index_name}.json"
|
||||
if not initial_index.exists():
|
||||
continue
|
||||
try:
|
||||
initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", [])
|
||||
except json.JSONDecodeError:
|
||||
initial_items = []
|
||||
if target_index.exists():
|
||||
try:
|
||||
target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", [])
|
||||
except json.JSONDecodeError:
|
||||
target_items = []
|
||||
else:
|
||||
target_items = []
|
||||
merged = []
|
||||
for item in [*target_items, *initial_items]:
|
||||
text = str(item).strip()
|
||||
if text in excluded:
|
||||
continue
|
||||
if text and text not in merged:
|
||||
merged.append(text)
|
||||
target_index.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
render_runtime_env_file() {
|
||||
local target_path="$1"
|
||||
|
||||
TARGET_PATH="$target_path" \
|
||||
AUTHZ_BASE_URL="$AUTHZ_BASE_URL" \
|
||||
AUTHZ_INTERNAL_TOKEN="$AUTHZ_INTERNAL_TOKEN" \
|
||||
AUTHZ_OUTLOOK_MCP_URL="$AUTHZ_OUTLOOK_MCP_URL" \
|
||||
BACKEND_ID="$BACKEND_ID" \
|
||||
CLIENT_ID="$CLIENT_ID" \
|
||||
CLIENT_SECRET="$CLIENT_SECRET" \
|
||||
BACKEND_NAME="$BACKEND_NAME" \
|
||||
PUBLIC_URL="$PUBLIC_URL" \
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
target = Path(os.environ["TARGET_PATH"])
|
||||
values = {
|
||||
"BEAVER_AUTHZ__ENABLED": "1" if os.environ["AUTHZ_BASE_URL"].strip() else "0",
|
||||
"BEAVER_AUTHZ__BASE_URL": os.environ["AUTHZ_BASE_URL"].strip(),
|
||||
"BEAVER_AUTHZ_INTERNAL_TOKEN": os.environ["AUTHZ_INTERNAL_TOKEN"].strip(),
|
||||
"BEAVER_AUTHZ__OUTLOOK_MCP_URL": os.environ["AUTHZ_OUTLOOK_MCP_URL"].strip(),
|
||||
"BEAVER_BACKEND_IDENTITY__BACKEND_ID": os.environ["BACKEND_ID"].strip(),
|
||||
"BEAVER_BACKEND_IDENTITY__CLIENT_ID": os.environ["CLIENT_ID"].strip(),
|
||||
"BEAVER_BACKEND_IDENTITY__CLIENT_SECRET": os.environ["CLIENT_SECRET"].strip(),
|
||||
"BEAVER_BACKEND_IDENTITY__NAME": os.environ["BACKEND_NAME"].strip(),
|
||||
"BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL": os.environ["PUBLIC_URL"].strip(),
|
||||
}
|
||||
ordered_keys = [
|
||||
"BEAVER_AUTHZ__ENABLED",
|
||||
"BEAVER_AUTHZ__BASE_URL",
|
||||
"BEAVER_AUTHZ_INTERNAL_TOKEN",
|
||||
"BEAVER_AUTHZ__OUTLOOK_MCP_URL",
|
||||
"BEAVER_BACKEND_IDENTITY__BACKEND_ID",
|
||||
"BEAVER_BACKEND_IDENTITY__CLIENT_ID",
|
||||
"BEAVER_BACKEND_IDENTITY__CLIENT_SECRET",
|
||||
"BEAVER_BACKEND_IDENTITY__NAME",
|
||||
"BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL",
|
||||
]
|
||||
lines: list[str] = []
|
||||
for key in ordered_keys:
|
||||
value = values.get(key, "")
|
||||
if value:
|
||||
lines.append(f"export {key}={shlex.quote(value)}")
|
||||
continue
|
||||
if key == "BEAVER_AUTHZ__ENABLED":
|
||||
lines.append("export BEAVER_AUTHZ__ENABLED=0")
|
||||
else:
|
||||
lines.append(f"unset {key}")
|
||||
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
PY
|
||||
}
|
||||
|
||||
image_exists() {
|
||||
docker image inspect "$IMAGE_NAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
container_exists() {
|
||||
docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--instance-id)
|
||||
INSTANCE_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--image)
|
||||
IMAGE_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--container-name)
|
||||
CONTAINER_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--host-port)
|
||||
HOST_PORT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--public-url)
|
||||
PUBLIC_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--provider)
|
||||
PROVIDER="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--api-key)
|
||||
API_KEY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--api-base)
|
||||
API_BASE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--model)
|
||||
MODEL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-provider-config)
|
||||
SKIP_PROVIDER_CONFIG=1
|
||||
shift
|
||||
;;
|
||||
--auth-username)
|
||||
AUTH_USERNAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--auth-password)
|
||||
AUTH_PASSWORD="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--username)
|
||||
USERNAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--email)
|
||||
EMAIL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--instance-host)
|
||||
INSTANCE_HOST="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--authz-base-url)
|
||||
AUTHZ_BASE_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--authz-internal-token)
|
||||
AUTHZ_INTERNAL_TOKEN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--authz-outlook-mcp-url)
|
||||
AUTHZ_OUTLOOK_MCP_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--outlook-mcp-server-id)
|
||||
OUTLOOK_MCP_SERVER_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--outlook-mcp-call-timeout-seconds)
|
||||
OUTLOOK_MCP_CALL_TIMEOUT_SECONDS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--user-files-max-upload-bytes)
|
||||
USER_FILES_MAX_UPLOAD_BYTES="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--external-connector-base-url)
|
||||
EXTERNAL_CONNECTOR_BASE_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--external-connector-token)
|
||||
EXTERNAL_CONNECTOR_TOKEN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--external-connector-callback-base-url)
|
||||
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--bridge-token)
|
||||
BEAVER_BRIDGE_TOKEN="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--backend-id)
|
||||
BACKEND_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--client-id)
|
||||
CLIENT_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--client-secret)
|
||||
CLIENT_SECRET="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--backend-name)
|
||||
BACKEND_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--auth-portal-url)
|
||||
AUTH_PORTAL_URL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--auth-portal-port)
|
||||
AUTH_PORTAL_PORT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--instances-root)
|
||||
INSTANCES_ROOT="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--registry)
|
||||
REGISTRY_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--network)
|
||||
NETWORK_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--host-bind-ip)
|
||||
HOST_BIND_IP="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--initial-skills-dir)
|
||||
INITIAL_SKILLS_DIR="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-initial-skills)
|
||||
SEED_INITIAL_SKILLS=0
|
||||
shift
|
||||
;;
|
||||
--build)
|
||||
FORCE_BUILD=1
|
||||
shift
|
||||
;;
|
||||
--replace)
|
||||
REPLACE=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$INSTANCE_ID" ]] || die "--instance-id is required"
|
||||
[[ -n "$AUTH_USERNAME" ]] || die "--auth-username is required"
|
||||
[[ -n "$AUTH_PASSWORD" ]] || die "--auth-password is required"
|
||||
if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then
|
||||
[[ -n "$API_KEY" ]] || die "--api-key is required unless --skip-provider-config is set"
|
||||
fi
|
||||
|
||||
INSTANCE_SLUG="$(slugify "$INSTANCE_ID")"
|
||||
USERNAME="${USERNAME:-$AUTH_USERNAME}"
|
||||
EXISTING_RECORD_JSON=""
|
||||
EXISTING_CONTAINER_NAME=""
|
||||
EXISTING_HOST_PORT=""
|
||||
|
||||
if EXISTING_RECORD_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" get --instance-id "$INSTANCE_ID" 2>/dev/null)"; then
|
||||
EXISTING_CONTAINER_NAME="$(printf '%s' "$EXISTING_RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["container_name"])')"
|
||||
EXISTING_HOST_PORT="$(printf '%s' "$EXISTING_RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["host_port"])')"
|
||||
if [[ "$REPLACE" -ne 1 ]]; then
|
||||
die "instance already exists in registry: ${INSTANCE_ID} (use --replace to recreate)"
|
||||
fi
|
||||
fi
|
||||
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-${EXISTING_CONTAINER_NAME:-app-instance-${INSTANCE_SLUG}}}"
|
||||
BACKEND_NAME="${BACKEND_NAME:-${INSTANCE_ID}}"
|
||||
|
||||
if [[ -z "$HOST_PORT" ]]; then
|
||||
HOST_PORT="${EXISTING_HOST_PORT:-$(pick_free_port)}"
|
||||
fi
|
||||
|
||||
if [[ -z "$PUBLIC_URL" ]]; then
|
||||
PUBLIC_URL="http://127.0.0.1:${HOST_PORT}"
|
||||
fi
|
||||
|
||||
if [[ -z "$INSTANCE_HOST" ]]; then
|
||||
INSTANCE_HOST="$(extract_url_host "$PUBLIC_URL")"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then
|
||||
case "$KNOWN_PROVIDERS" in
|
||||
*" ${PROVIDER} "*) ;;
|
||||
*) die "unsupported provider '${PROVIDER}'" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ -n "$INITIAL_SKILLS_DIR" ]]; then
|
||||
INITIAL_SKILLS_DIR="$(INITIAL_SKILLS_DIR="$INITIAL_SKILLS_DIR" python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
print(Path(os.environ["INITIAL_SKILLS_DIR"]).expanduser().resolve())
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
|
||||
if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then
|
||||
[[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together"
|
||||
fi
|
||||
|
||||
if PORT_HOLDER_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" get --container-name "$CONTAINER_NAME" 2>/dev/null)"; then
|
||||
REGISTERED_ID="$(printf '%s' "$PORT_HOLDER_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["instance_id"])')"
|
||||
if [[ "$REGISTERED_ID" != "$INSTANCE_ID" && "$REPLACE" -ne 1 ]]; then
|
||||
die "container name already registered for another instance: ${CONTAINER_NAME}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if PORT_HOLDER_JSON="$("$REGISTRY_TOOL" --registry "$REGISTRY_PATH" list --json)"; then
|
||||
PORT_CONFLICT_ID="$(PORT_HOLDER_JSON="$PORT_HOLDER_JSON" python3 - "$HOST_PORT" "$INSTANCE_ID" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
port = int(sys.argv[1])
|
||||
instance_id = sys.argv[2]
|
||||
for item in json.loads(os.environ["PORT_HOLDER_JSON"]).get("instances", []):
|
||||
if int(item.get("host_port", 0) or 0) == port and item.get("instance_id") != instance_id:
|
||||
print(item.get("instance_id", ""))
|
||||
break
|
||||
PY
|
||||
)"
|
||||
if [[ -n "$PORT_CONFLICT_ID" ]]; then
|
||||
die "host port already registered by another instance: ${HOST_PORT} (${PORT_CONFLICT_ID})"
|
||||
fi
|
||||
fi
|
||||
|
||||
INSTANCE_ROOT="${INSTANCES_ROOT}/${INSTANCE_SLUG}"
|
||||
BEAVER_HOME="${INSTANCE_ROOT}/beaver-home"
|
||||
CONFIG_PATH="${BEAVER_HOME}/config.json"
|
||||
AUTH_USERS_PATH="${BEAVER_HOME}/web_auth_users.json"
|
||||
RUNTIME_ENV_PATH="${BEAVER_HOME}/runtime.env"
|
||||
WORKSPACE_PATH="${BEAVER_HOME}/workspace"
|
||||
|
||||
mkdir -p "$BEAVER_HOME" "$WORKSPACE_PATH"
|
||||
|
||||
render_config_json "$CONFIG_PATH"
|
||||
render_auth_users_json "$AUTH_USERS_PATH"
|
||||
render_runtime_env_file "$RUNTIME_ENV_PATH"
|
||||
seed_initial_skills "$WORKSPACE_PATH" "$INITIAL_SKILLS_DIR"
|
||||
|
||||
if [[ "$FORCE_BUILD" -eq 1 ]] || ! image_exists; then
|
||||
log "building image ${IMAGE_NAME}"
|
||||
docker build \
|
||||
--build-arg "NEXT_PUBLIC_AUTH_PORTAL_URL=${AUTH_PORTAL_URL}" \
|
||||
--build-arg "NEXT_PUBLIC_AUTH_PORTAL_PORT=${AUTH_PORTAL_PORT}" \
|
||||
-t "$IMAGE_NAME" \
|
||||
"$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
if container_exists; then
|
||||
if [[ "$REPLACE" -eq 1 ]]; then
|
||||
log "removing existing container ${CONTAINER_NAME}"
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null
|
||||
else
|
||||
die "container already exists: ${CONTAINER_NAME} (use --replace to recreate)"
|
||||
fi
|
||||
fi
|
||||
|
||||
RUN_ARGS=(
|
||||
-d
|
||||
--name "$CONTAINER_NAME"
|
||||
--restart unless-stopped
|
||||
-p "${HOST_BIND_IP}:${HOST_PORT}:8080"
|
||||
-v "${BEAVER_HOME}:/root/.beaver"
|
||||
-e "BEAVER_HOME=/root/.beaver"
|
||||
-e "BEAVER_CONFIG_PATH=/root/.beaver/config.json"
|
||||
-e "BEAVER_WORKSPACE=/root/.beaver/workspace"
|
||||
-e "BEAVER_AUTH_FILE=/root/.beaver/web_auth_users.json"
|
||||
-e "BEAVER_FRONTEND_PUBLIC_BASE_URL=${PUBLIC_URL}"
|
||||
-e "APP_PUBLIC_PORT=8080"
|
||||
-e "APP_FRONTEND_PORT=3000"
|
||||
-e "APP_BACKEND_PORT=18080"
|
||||
-e "BEAVER_ENABLE_SELF_RESTART=1"
|
||||
-e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}"
|
||||
-e "BEAVER_OUTLOOK_MCP_CALL_TIMEOUT_SECONDS=${OUTLOOK_MCP_CALL_TIMEOUT_SECONDS}"
|
||||
-e "EXTERNAL_CONNECTOR_BASE_URL=${EXTERNAL_CONNECTOR_BASE_URL}"
|
||||
--label "beaver.instance.id=${INSTANCE_ID}"
|
||||
--label "beaver.instance.slug=${INSTANCE_SLUG}"
|
||||
--label "beaver.instance.public_url=${PUBLIC_URL}"
|
||||
)
|
||||
|
||||
if [[ -z "$EXTERNAL_CONNECTOR_CALLBACK_BASE_URL" && -n "$NETWORK_NAME" ]]; then
|
||||
EXTERNAL_CONNECTOR_CALLBACK_BASE_URL="http://${CONTAINER_NAME}:8080"
|
||||
fi
|
||||
|
||||
if [[ "$SEED_INITIAL_SKILLS" -eq 1 && -n "$INITIAL_SKILLS_DIR" ]]; then
|
||||
RUN_ARGS+=(
|
||||
-v "${INITIAL_SKILLS_DIR}:/opt/app/initial-skills:ro"
|
||||
-e "BEAVER_INITIAL_SKILLS_DIR=/opt/app/initial-skills"
|
||||
-e "BEAVER_INITIAL_SKILLS_EXCLUDE=${INITIAL_SKILLS_EXCLUDE}"
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ -n "$USER_FILES_MAX_UPLOAD_BYTES" ]]; then
|
||||
RUN_ARGS+=(-e "BEAVER_USER_FILES_MAX_UPLOAD_BYTES=${USER_FILES_MAX_UPLOAD_BYTES}")
|
||||
fi
|
||||
if [[ -n "$EXTERNAL_CONNECTOR_TOKEN" ]]; then
|
||||
RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_TOKEN=${EXTERNAL_CONNECTOR_TOKEN}")
|
||||
fi
|
||||
if [[ -n "$EXTERNAL_CONNECTOR_CALLBACK_BASE_URL" ]]; then
|
||||
RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_CALLBACK_BASE_URL=${EXTERNAL_CONNECTOR_CALLBACK_BASE_URL}")
|
||||
fi
|
||||
if [[ -n "$BEAVER_BRIDGE_TOKEN" ]]; then
|
||||
RUN_ARGS+=(-e "BEAVER_BRIDGE_TOKEN=${BEAVER_BRIDGE_TOKEN}")
|
||||
fi
|
||||
|
||||
if [[ -n "$NETWORK_NAME" ]]; then
|
||||
RUN_ARGS+=(--network "$NETWORK_NAME")
|
||||
fi
|
||||
|
||||
log "starting container ${CONTAINER_NAME}"
|
||||
docker run "${RUN_ARGS[@]}" "$IMAGE_NAME" >/dev/null
|
||||
|
||||
"$REGISTRY_TOOL" --registry "$REGISTRY_PATH" upsert \
|
||||
--instance-id "$INSTANCE_ID" \
|
||||
--instance-slug "$INSTANCE_SLUG" \
|
||||
--container-name "$CONTAINER_NAME" \
|
||||
--image-name "$IMAGE_NAME" \
|
||||
--host-port "$HOST_PORT" \
|
||||
--public-url "$PUBLIC_URL" \
|
||||
--instance-root "$INSTANCE_ROOT" \
|
||||
--beaver-home "$BEAVER_HOME" \
|
||||
--config-path "$CONFIG_PATH" \
|
||||
--auth-users-path "$AUTH_USERS_PATH" \
|
||||
--network-name "$NETWORK_NAME" \
|
||||
--backend-id "$BACKEND_ID" \
|
||||
--backend-name "$BACKEND_NAME" \
|
||||
--authz-base-url "$AUTHZ_BASE_URL" \
|
||||
--username "$USERNAME" \
|
||||
--email "$EMAIL" \
|
||||
--instance-host "$INSTANCE_HOST" \
|
||||
--frontend-base-url "$PUBLIC_URL" \
|
||||
--api-base-url "$PUBLIC_URL" \
|
||||
--created-at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >/dev/null
|
||||
|
||||
cat <<EOF
|
||||
instance_id=${INSTANCE_ID}
|
||||
instance_slug=${INSTANCE_SLUG}
|
||||
container_name=${CONTAINER_NAME}
|
||||
image_name=${IMAGE_NAME}
|
||||
host_port=${HOST_PORT}
|
||||
public_url=${PUBLIC_URL}
|
||||
instance_root=${INSTANCE_ROOT}
|
||||
beaver_home=${BEAVER_HOME}
|
||||
config_path=${CONFIG_PATH}
|
||||
auth_users_path=${AUTH_USERS_PATH}
|
||||
runtime_env_path=${RUNTIME_ENV_PATH}
|
||||
username=${USERNAME}
|
||||
email=${EMAIL}
|
||||
instance_host=${INSTANCE_HOST}
|
||||
host_bind_ip=${HOST_BIND_IP}
|
||||
EOF
|
||||
@ -10,7 +10,6 @@ UVICORN_WS="${UVICORN_WS:-websockets}"
|
||||
BEAVER_HOME="${BEAVER_HOME:-/root/.beaver}"
|
||||
BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}"
|
||||
BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}"
|
||||
BEAVER_AUTH_FILE="${BEAVER_AUTH_FILE:-$BEAVER_HOME/web_auth_users.json}"
|
||||
BEAVER_RUNTIME_ENV_FILE="${BEAVER_RUNTIME_ENV_FILE:-$BEAVER_HOME/runtime.env}"
|
||||
BEAVER_INITIAL_SKILLS_DIR="${BEAVER_INITIAL_SKILLS_DIR:-/opt/app/initial-skills}"
|
||||
BEAVER_INITIAL_SKILLS_EXCLUDE="${BEAVER_INITIAL_SKILLS_EXCLUDE:-officebench-mcp}"
|
||||
@ -120,7 +119,6 @@ fi
|
||||
require_file "$BEAVER_CONFIG_PATH" "Missing Beaver config"
|
||||
seed_initial_skills "$BEAVER_INITIAL_SKILLS_DIR" "$BEAVER_WORKSPACE/skills"
|
||||
|
||||
export BEAVER_AUTH_FILE
|
||||
export BEAVER_RUNTIME_ENV_FILE
|
||||
export BEAVER_HOME
|
||||
export BEAVER_CONFIG_PATH
|
||||
|
||||
@ -119,13 +119,17 @@ npm install
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:10000
|
||||
NEXT_PUBLIC_WS_URL=wss://127.0.0.1:10000
|
||||
NEXT_PUBLIC_AUTH_PORTAL_URL=http://127.0.0.1:3081
|
||||
NEXT_PUBLIC_KEYCLOAK_ISSUER=https://keycloak.bwgdi.com/realms/beaver
|
||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=beaver-agnet
|
||||
NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI=http://172.19.0.245:18080/auth/callback
|
||||
NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=http://172.19.0.245:18080/logout/callback
|
||||
```
|
||||
|
||||
当前前端的地址策略是:
|
||||
|
||||
- 如果配置了 `NEXT_PUBLIC_API_URL` / `NEXT_PUBLIC_WS_URL`,优先使用显式配置
|
||||
- 如果配置了 `NEXT_PUBLIC_AUTH_PORTAL_URL`,未登录跳转会优先去独立 auth portal
|
||||
- 未登录会跳转到 `NEXT_PUBLIC_KEYCLOAK_ISSUER` 的 Authorization Code + PKCE 登录页
|
||||
- 退出登录会跳转到 Keycloak logout endpoint,并通过 `NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI` 回到前端
|
||||
- 如果未配置,浏览器端会优先使用当前站点同源地址
|
||||
|
||||
### 启动开发环境
|
||||
@ -241,7 +245,7 @@ docker build \
|
||||
|
||||
### 2. 技术标识
|
||||
|
||||
当前前端使用 Beaver 技术命名,本地 token、语言和 handoff 状态都使用 `beaver_*` key。
|
||||
当前前端使用 Beaver 技术命名,本地 token、语言和 Keycloak 登录状态都使用 `beaver_*` key。
|
||||
|
||||
### 3. 动态内容可能仍包含英文
|
||||
|
||||
|
||||
@ -3,23 +3,45 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { buildAuthPortalUrl } from '@/lib/auth-portal';
|
||||
import { startKeycloakLogin } from '@/lib/keycloak-oidc';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
export default function LoginRedirectPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const loggedOut = searchParams?.get('logged_out') === '1';
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedOut) return;
|
||||
const nextPath = searchParams?.get('next') || '/';
|
||||
window.location.replace(buildAuthPortalUrl('/login', nextPath));
|
||||
}, [searchParams]);
|
||||
void startKeycloakLogin(nextPath);
|
||||
}, [loggedOut, searchParams]);
|
||||
|
||||
if (loggedOut) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '你已退出登录。', 'You have signed out.')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-primary px-5 py-2 text-sm font-semibold text-primary-foreground"
|
||||
onClick={() => {
|
||||
const nextPath = searchParams?.get('next') || '/';
|
||||
void startKeycloakLogin(nextPath);
|
||||
}}
|
||||
>
|
||||
{pickAppText(locale, '重新登录', 'Sign in again')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '正在跳转到登录门户...', 'Redirecting to the sign-in portal...')}
|
||||
{pickAppText(locale, '正在跳转到 Keycloak 登录...', 'Redirecting to Keycloak sign-in...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { buildAuthPortalUrl } from '@/lib/auth-portal';
|
||||
import { startKeycloakLogin } from '@/lib/keycloak-oidc';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
|
||||
@ -13,13 +13,13 @@ export default function RegisterRedirectPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const nextPath = searchParams?.get('next') || '/mcp';
|
||||
window.location.replace(buildAuthPortalUrl('/register', nextPath));
|
||||
void startKeycloakLogin(nextPath);
|
||||
}, [searchParams]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '正在跳转到注册门户...', 'Redirecting to the sign-up portal...')}
|
||||
{pickAppText(locale, '正在跳转到 Keycloak 登录...', 'Redirecting to Keycloak sign-in...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
68
app-instance/frontend/app/auth/callback/page.tsx
Normal file
68
app-instance/frontend/app/auth/callback/page.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import {
|
||||
clearLoginState,
|
||||
exchangeKeycloakCallback,
|
||||
loadLoginState,
|
||||
parseAuthCallbackUrl,
|
||||
} from '@/lib/keycloak-oidc';
|
||||
import { getMe } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const router = useRouter();
|
||||
const setUser = useChatStore((s) => s.setUser);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const completeLogin = async () => {
|
||||
const callback = parseAuthCallbackUrl();
|
||||
if (callback.error) {
|
||||
throw new Error(callback.errorDescription || callback.error);
|
||||
}
|
||||
if (!callback.code || !callback.state) {
|
||||
throw new Error('Missing Keycloak callback code or state');
|
||||
}
|
||||
const loginState = loadLoginState();
|
||||
if (!loginState || loginState.state !== callback.state) {
|
||||
throw new Error('Invalid Keycloak login state');
|
||||
}
|
||||
|
||||
const result = await exchangeKeycloakCallback({ code: callback.code, state: loginState });
|
||||
clearLoginState();
|
||||
const user = await getMe().catch(() => result.user);
|
||||
if (cancelled) return;
|
||||
setUser(user);
|
||||
router.replace(loginState.nextPath || '/');
|
||||
};
|
||||
|
||||
completeLogin().catch((exc) => {
|
||||
clearLoginState();
|
||||
if (!cancelled) {
|
||||
setError(exc instanceof Error ? exc.message : String(exc));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router, setUser]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="max-w-lg text-center text-sm text-muted-foreground">
|
||||
{error
|
||||
? pickAppText(locale, `登录失败:${error}`, `Sign-in failed: ${error}`)
|
||||
: pickAppText(locale, '正在完成登录...', 'Completing sign-in...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,146 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { clearTokens, consumeHandoffCode, getMe, setTokens } from '@/lib/api';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
const HANDOFF_STATE_KEY = 'beaver_handoff_state';
|
||||
|
||||
type HandoffState = {
|
||||
code?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
nextPath?: string;
|
||||
};
|
||||
|
||||
function parseHandoffStateFromLocation(): HandoffState {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const code = query.get('code') || '';
|
||||
const nextFromQuery = query.get('next') || '';
|
||||
if (code) {
|
||||
return {
|
||||
code,
|
||||
nextPath: nextFromQuery || '/',
|
||||
};
|
||||
}
|
||||
|
||||
const rawHash = window.location.hash.startsWith('#')
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash;
|
||||
const hash = new URLSearchParams(rawHash);
|
||||
const accessToken = hash.get('access_token') || '';
|
||||
if (accessToken) {
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: hash.get('refresh_token') || '',
|
||||
nextPath: hash.get('next') || '/',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function loadHandoffState(): HandoffState {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const fromLocation = parseHandoffStateFromLocation();
|
||||
if (fromLocation.code || fromLocation.accessToken) {
|
||||
sessionStorage.setItem(HANDOFF_STATE_KEY, JSON.stringify(fromLocation));
|
||||
return fromLocation;
|
||||
}
|
||||
|
||||
const cached = sessionStorage.getItem(HANDOFF_STATE_KEY) || '';
|
||||
if (!cached) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cached) as HandoffState;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function clearHandoffState(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(HANDOFF_STATE_KEY);
|
||||
}
|
||||
|
||||
export default function HandoffPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const router = useRouter();
|
||||
const setUser = useChatStore((s) => s.setUser);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const handoff = loadHandoffState();
|
||||
const nextPath = handoff.nextPath || '/';
|
||||
|
||||
if (!handoff.code && !handoff.accessToken) {
|
||||
clearHandoffState();
|
||||
setError(pickAppText(locale, '缺少登录凭证,无法进入目标前端。', 'Missing login credentials. Unable to enter the target frontend.'));
|
||||
return;
|
||||
}
|
||||
|
||||
window.history.replaceState(null, '', '/handoff');
|
||||
|
||||
try {
|
||||
const tokenPayload = handoff.accessToken
|
||||
? {
|
||||
access_token: handoff.accessToken,
|
||||
refresh_token: handoff.refreshToken || '',
|
||||
}
|
||||
: await consumeHandoffCode(handoff.code || '');
|
||||
|
||||
setTokens(tokenPayload.access_token, tokenPayload.refresh_token || '');
|
||||
|
||||
const me = await getMe();
|
||||
if (cancelled) return;
|
||||
clearHandoffState();
|
||||
setUser(me);
|
||||
router.replace(nextPath.startsWith('/') ? nextPath : '/');
|
||||
} catch (err) {
|
||||
clearHandoffState();
|
||||
clearTokens();
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : pickAppText(locale, '目标前端登录失败', 'Target frontend sign-in failed'));
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router, setUser]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold">{pickAppText(locale, '正在进入目标前端...', 'Entering the target frontend...')}</h1>
|
||||
{error ? (
|
||||
<p className="mt-3 text-sm text-red-400">{error}</p>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '正在同步登录态,请稍候。', 'Syncing your sign-in state. Please wait.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
app-instance/frontend/app/logout/callback/page.tsx
Normal file
32
app-instance/frontend/app/logout/callback/page.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { clearTokens } from '@/lib/api';
|
||||
import { clearKeycloakLogoutInProgress, clearLoginState } from '@/lib/keycloak-oidc';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
|
||||
export default function LogoutCallbackPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const router = useRouter();
|
||||
const setUser = useChatStore((s) => s.setUser);
|
||||
|
||||
useEffect(() => {
|
||||
clearTokens();
|
||||
clearLoginState();
|
||||
clearKeycloakLogoutInProgress();
|
||||
setUser(null);
|
||||
router.replace('/login?logged_out=1');
|
||||
}, [router, setUser]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pickAppText(locale, '正在退出登录...', 'Signing out...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { buildAuthPortalUrl } from '@/lib/auth-portal';
|
||||
import { clearTokens, getMe, isLoggedIn } from '@/lib/api';
|
||||
import { isKeycloakLogoutInProgress, startKeycloakLogin } from '@/lib/keycloak-oidc';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
@ -71,13 +71,16 @@ export default function AuthGuard({
|
||||
return;
|
||||
}
|
||||
|
||||
const isPublicRoute = pathname === '/login' || pathname === '/register';
|
||||
const isPublicRoute = pathname === '/login' || pathname === '/register' || pathname === '/auth/callback' || pathname === '/logout/callback';
|
||||
const loggedIn = isLoggedIn();
|
||||
|
||||
if (!loggedIn && !isPublicRoute) {
|
||||
if (isKeycloakLogoutInProgress()) {
|
||||
return;
|
||||
}
|
||||
const search = searchParams?.toString();
|
||||
const nextPath = search ? `${pathname}?${search}` : pathname;
|
||||
window.location.replace(buildAuthPortalUrl('/login', nextPath));
|
||||
void startKeycloakLogin(nextPath);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -94,7 +97,7 @@ export default function AuthGuard({
|
||||
);
|
||||
}
|
||||
|
||||
const isPublicRoute = pathname === '/login' || pathname === '/register';
|
||||
const isPublicRoute = pathname === '/login' || pathname === '/register' || pathname === '/auth/callback' || pathname === '/logout/callback';
|
||||
if (!isPublicRoute && (!isLoggedIn() || !user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -2,9 +2,10 @@
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Bell, Bot, ChevronDown, FolderOpen, ListTodo, LogOut, Mail, Menu, MessageSquare, Puzzle, Settings, Store, Wrench, X } from 'lucide-react';
|
||||
import { logout } from '@/lib/api';
|
||||
import { clearTokens, getIdToken, logout } from '@/lib/api';
|
||||
import { buildKeycloakLogoutUrl, markKeycloakLogoutInProgress } from '@/lib/keycloak-oidc';
|
||||
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -68,7 +69,6 @@ function ConnectionDot() {
|
||||
const Header = () => {
|
||||
const { locale } = useAppI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
const user = useChatStore((s) => s.user);
|
||||
const isAuthLoading = useChatStore((s) => s.isAuthLoading);
|
||||
@ -87,11 +87,13 @@ const Header = () => {
|
||||
return pickAppText(locale, '配置', 'Settings');
|
||||
}, [locale]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
const handleLogout = () => {
|
||||
const logoutUrl = buildKeycloakLogoutUrl(getIdToken());
|
||||
markKeycloakLogoutInProgress();
|
||||
void logout();
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
router.replace('/login');
|
||||
router.refresh();
|
||||
window.location.assign(logoutUrl);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
# 单用户后端地址(Boardware Genius Web 后端)
|
||||
NEXT_PUBLIC_API_URL=http://127.0.0.1:10000
|
||||
NEXT_PUBLIC_WS_URL=wss://127.0.0.1:10000
|
||||
NEXT_PUBLIC_AUTH_PORTAL_URL=http://127.0.0.1:3081
|
||||
NEXT_PUBLIC_KEYCLOAK_ISSUER=https://keycloak.bwgdi.com/realms/beaver
|
||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=beaver-agnet
|
||||
NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI=http://172.19.0.245:18080/auth/callback
|
||||
NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=http://172.19.0.245:18080/logout/callback
|
||||
|
||||
@ -58,6 +58,7 @@ const WS_URL = process.env.NEXT_PUBLIC_WS_URL?.trim();
|
||||
const DEFAULT_API_URL = 'http://127.0.0.1:18080';
|
||||
const ACCESS_TOKEN_KEY = 'beaver_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'beaver_refresh_token';
|
||||
const ID_TOKEN_KEY = 'beaver_id_token';
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
const OUTLOOK_REQUEST_TIMEOUT_MS = 45000;
|
||||
const SKILL_LEARNING_REQUEST_TIMEOUT_MS = 120000;
|
||||
@ -75,31 +76,6 @@ function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function buildAuthHandoffUrl(response: TokenResponse, nextPath: string): string | null {
|
||||
const targetBaseUrl = normalizeBaseUrl(
|
||||
response.backend_connection?.frontend_base_url ||
|
||||
response.backend_connection?.public_base_url ||
|
||||
response.backend_connection?.api_base_url ||
|
||||
response.local_backend?.public_base_url
|
||||
);
|
||||
if (!targetBaseUrl) return null;
|
||||
const handoffCode = response.handoff_code?.trim();
|
||||
if (!handoffCode) return null;
|
||||
|
||||
const target = new URL('/handoff', targetBaseUrl);
|
||||
target.searchParams.set('code', handoffCode);
|
||||
if (nextPath) {
|
||||
target.searchParams.set('next', nextPath);
|
||||
}
|
||||
return target.toString();
|
||||
}
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
if (API_URL) return API_URL;
|
||||
if (isBrowser()) return window.location.origin;
|
||||
@ -153,16 +129,31 @@ export function getRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh: string): void {
|
||||
export function getIdToken(): string | null {
|
||||
if (!isBrowser()) return null;
|
||||
return localStorage.getItem(ID_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(access: string, refresh: string, idToken: string = ''): void {
|
||||
if (!isBrowser()) return;
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, access);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
|
||||
if (refresh) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
|
||||
} else {
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
if (idToken) {
|
||||
localStorage.setItem(ID_TOKEN_KEY, idToken);
|
||||
} else {
|
||||
localStorage.removeItem(ID_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
if (!isBrowser()) return;
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(ID_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
@ -233,27 +224,6 @@ async function fetchJSON<T>(path: string, options?: FetchJsonOptions): Promise<T
|
||||
// Auth API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function register(username: string, email: string, password: string): Promise<TokenResponse> {
|
||||
return fetchJSON('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<TokenResponse> {
|
||||
return fetchJSON('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function consumeHandoffCode(code: string): Promise<TokenResponse> {
|
||||
return fetchJSON('/api/auth/handoff/consume', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
await fetchJSON('/api/auth/logout', { method: 'POST' });
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
'use client';
|
||||
|
||||
const AUTH_PORTAL_URL = process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim();
|
||||
const AUTH_PORTAL_PORT = process.env.NEXT_PUBLIC_AUTH_PORTAL_PORT?.trim() || '3081';
|
||||
|
||||
function normalizeBaseUrl(value?: string | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getPortalBaseUrl(): string {
|
||||
const explicit = normalizeBaseUrl(AUTH_PORTAL_URL);
|
||||
if (explicit) return explicit;
|
||||
if (typeof window !== 'undefined') {
|
||||
const url = new URL(window.location.origin);
|
||||
url.port = AUTH_PORTAL_PORT;
|
||||
return normalizeBaseUrl(url.toString()) || window.location.origin;
|
||||
}
|
||||
return `http://127.0.0.1:${AUTH_PORTAL_PORT}`;
|
||||
}
|
||||
|
||||
export function buildAuthPortalUrl(path: '/login' | '/register', nextPath?: string | null): string {
|
||||
const url = new URL(path, `${getPortalBaseUrl()}/`);
|
||||
const nextValue = nextPath?.trim();
|
||||
if (nextValue) {
|
||||
url.searchParams.set('next', nextValue);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
116
app-instance/frontend/lib/keycloak-oidc.test.ts
Normal file
116
app-instance/frontend/lib/keycloak-oidc.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
KEYCLOAK_LOGIN_STATE_KEY,
|
||||
KEYCLOAK_LOGOUT_IN_PROGRESS_KEY,
|
||||
buildKeycloakAuthorizeUrl,
|
||||
buildKeycloakLogoutUrl,
|
||||
clearKeycloakLogoutInProgress,
|
||||
createCodeChallenge,
|
||||
isKeycloakLogoutInProgress,
|
||||
markKeycloakLogoutInProgress,
|
||||
parseAuthCallbackUrl,
|
||||
} from './keycloak-oidc';
|
||||
|
||||
function createStorage() {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => data.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => data.set(key, value),
|
||||
removeItem: (key: string) => data.delete(key),
|
||||
clear: () => data.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
const testSessionStorage = createStorage();
|
||||
const testWindow = {
|
||||
location: new URL('http://172.19.0.245:18080/login'),
|
||||
sessionStorage: testSessionStorage,
|
||||
crypto: globalThis.crypto,
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: testWindow,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
configurable: true,
|
||||
value: testSessionStorage,
|
||||
});
|
||||
|
||||
function setWindowLocation(url: string) {
|
||||
testWindow.location = new URL(url);
|
||||
}
|
||||
|
||||
describe('keycloak oidc helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-06-15T08:00:00.000Z'));
|
||||
testSessionStorage.clear();
|
||||
setWindowLocation('http://172.19.0.245:18080/login');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
testSessionStorage.clear();
|
||||
});
|
||||
|
||||
test('builds a Keycloak authorization URL using code flow with PKCE and nonce', () => {
|
||||
const url = new URL(buildKeycloakAuthorizeUrl({
|
||||
state: 'state-1',
|
||||
nonce: 'nonce-1',
|
||||
codeChallenge: 'challenge-1',
|
||||
nextPath: '/files',
|
||||
}));
|
||||
|
||||
expect(url.origin + url.pathname).toBe('https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/auth');
|
||||
expect(url.searchParams.get('client_id')).toBe('beaver-agnet');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('scope')).toBe('openid profile email');
|
||||
expect(url.searchParams.get('redirect_uri')).toBe('http://172.19.0.245:18080/auth/callback');
|
||||
expect(url.searchParams.get('state')).toBe('state-1');
|
||||
expect(url.searchParams.get('nonce')).toBe('nonce-1');
|
||||
expect(url.searchParams.get('code_challenge')).toBe('challenge-1');
|
||||
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
});
|
||||
|
||||
test('creates the RFC 7636 S256 challenge without requiring https WebCrypto', async () => {
|
||||
const challenge = await createCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk');
|
||||
|
||||
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
|
||||
});
|
||||
|
||||
test('builds a logout URL with post_logout_redirect_uri and id_token_hint', () => {
|
||||
const url = new URL(buildKeycloakLogoutUrl('id-token-1'));
|
||||
|
||||
expect(url.origin + url.pathname).toBe('https://keycloak.bwgdi.com/realms/beaver/protocol/openid-connect/logout');
|
||||
expect(url.searchParams.get('client_id')).toBe('beaver-agnet');
|
||||
expect(url.searchParams.get('id_token_hint')).toBe('id-token-1');
|
||||
expect(url.searchParams.get('post_logout_redirect_uri')).toBe('http://172.19.0.245:18080/logout/callback');
|
||||
});
|
||||
|
||||
test('tracks logout progress briefly to prevent immediate login restart', () => {
|
||||
expect(isKeycloakLogoutInProgress()).toBe(false);
|
||||
|
||||
markKeycloakLogoutInProgress();
|
||||
|
||||
expect(testSessionStorage.getItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY)).toBe('1781510400000');
|
||||
expect(isKeycloakLogoutInProgress()).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(121_000);
|
||||
|
||||
expect(isKeycloakLogoutInProgress()).toBe(false);
|
||||
});
|
||||
|
||||
test('parses callback code and state from the current URL', () => {
|
||||
setWindowLocation('http://172.19.0.245:18080/auth/callback?code=abc&state=xyz');
|
||||
|
||||
expect(parseAuthCallbackUrl()).toEqual({ code: 'abc', state: 'xyz', error: '', errorDescription: '' });
|
||||
});
|
||||
|
||||
test('exports stable session storage keys', () => {
|
||||
expect(KEYCLOAK_LOGIN_STATE_KEY).toBe('beaver_keycloak_login_state');
|
||||
clearKeycloakLogoutInProgress();
|
||||
expect(testSessionStorage.getItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
324
app-instance/frontend/lib/keycloak-oidc.ts
Normal file
324
app-instance/frontend/lib/keycloak-oidc.ts
Normal file
@ -0,0 +1,324 @@
|
||||
import type { AuthUser, TokenResponse } from '@/types';
|
||||
import { setTokens } from '@/lib/api';
|
||||
|
||||
export const KEYCLOAK_LOGIN_STATE_KEY = 'beaver_keycloak_login_state';
|
||||
export const KEYCLOAK_LOGOUT_IN_PROGRESS_KEY = 'beaver_keycloak_logout_in_progress';
|
||||
|
||||
const DEFAULT_KEYCLOAK_ISSUER = 'https://keycloak.bwgdi.com/realms/beaver';
|
||||
const DEFAULT_KEYCLOAK_CLIENT_ID = 'beaver-agnet';
|
||||
const DEFAULT_SCOPE = 'openid profile email';
|
||||
const LOGOUT_PROGRESS_TTL_MS = 120_000;
|
||||
|
||||
export type KeycloakLoginState = {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
redirectUri: string;
|
||||
nextPath: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type KeycloakCallbackParams = {
|
||||
code: string;
|
||||
state: string;
|
||||
error: string;
|
||||
errorDescription: string;
|
||||
};
|
||||
|
||||
type AuthorizeUrlInput = {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeChallenge: string;
|
||||
nextPath?: string;
|
||||
};
|
||||
|
||||
type CallbackExchangeInput = {
|
||||
code: string;
|
||||
state: KeycloakLoginState;
|
||||
};
|
||||
|
||||
type CallbackExchangeResult = {
|
||||
token: TokenResponse;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
function cleanBaseUrl(value: string): string {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function envValue(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
return value || '';
|
||||
}
|
||||
|
||||
export function getKeycloakIssuer(): string {
|
||||
return cleanBaseUrl(envValue('NEXT_PUBLIC_KEYCLOAK_ISSUER') || DEFAULT_KEYCLOAK_ISSUER);
|
||||
}
|
||||
|
||||
export function getKeycloakClientId(): string {
|
||||
return envValue('NEXT_PUBLIC_KEYCLOAK_CLIENT_ID') || DEFAULT_KEYCLOAK_CLIENT_ID;
|
||||
}
|
||||
|
||||
export function getKeycloakRedirectUri(): string {
|
||||
const configured = envValue('NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI');
|
||||
if (configured) return configured;
|
||||
if (isBrowser()) return `${window.location.origin}/auth/callback`;
|
||||
return '/auth/callback';
|
||||
}
|
||||
|
||||
export function getKeycloakPostLogoutRedirectUri(): string {
|
||||
const configured = envValue('NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI');
|
||||
if (configured) return configured;
|
||||
if (isBrowser()) return `${window.location.origin}/logout/callback`;
|
||||
return '/logout/callback';
|
||||
}
|
||||
|
||||
export function buildKeycloakAuthorizeUrl(input: AuthorizeUrlInput): string {
|
||||
const url = new URL(`${getKeycloakIssuer()}/protocol/openid-connect/auth`);
|
||||
url.searchParams.set('client_id', getKeycloakClientId());
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', DEFAULT_SCOPE);
|
||||
url.searchParams.set('redirect_uri', getKeycloakRedirectUri());
|
||||
url.searchParams.set('state', input.state);
|
||||
url.searchParams.set('nonce', input.nonce);
|
||||
url.searchParams.set('code_challenge', input.codeChallenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildKeycloakLogoutUrl(idToken?: string | null): string {
|
||||
const url = new URL(`${getKeycloakIssuer()}/protocol/openid-connect/logout`);
|
||||
url.searchParams.set('client_id', getKeycloakClientId());
|
||||
url.searchParams.set('post_logout_redirect_uri', getKeycloakPostLogoutRedirectUri());
|
||||
if (idToken) {
|
||||
url.searchParams.set('id_token_hint', idToken);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function base64Url(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function rotateRight(value: number, shift: number): number {
|
||||
return (value >>> shift) | (value << (32 - shift));
|
||||
}
|
||||
|
||||
function sha256Fallback(input: string): Uint8Array {
|
||||
const bytes = new TextEncoder().encode(input);
|
||||
const bitLength = bytes.length * 8;
|
||||
const paddedLength = (((bytes.length + 9 + 63) >> 6) << 6);
|
||||
const padded = new Uint8Array(paddedLength);
|
||||
padded.set(bytes);
|
||||
padded[bytes.length] = 0x80;
|
||||
const view = new DataView(padded.buffer);
|
||||
view.setUint32(paddedLength - 4, bitLength, false);
|
||||
|
||||
const h = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
];
|
||||
const k = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
];
|
||||
const w = new Array<number>(64);
|
||||
|
||||
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
w[i] = view.getUint32(offset + i * 4, false);
|
||||
}
|
||||
for (let i = 16; i < 64; i += 1) {
|
||||
const s0 = rotateRight(w[i - 15], 7) ^ rotateRight(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
||||
const s1 = rotateRight(w[i - 2], 17) ^ rotateRight(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
||||
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
||||
}
|
||||
|
||||
let [a, b, c, d, e, f, g, hh] = h;
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
const s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const temp1 = (hh + s1 + ch + k[i] + w[i]) >>> 0;
|
||||
const s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22);
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const temp2 = (s0 + maj) >>> 0;
|
||||
hh = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = (d + temp1) >>> 0;
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = (temp1 + temp2) >>> 0;
|
||||
}
|
||||
|
||||
h[0] = (h[0] + a) >>> 0;
|
||||
h[1] = (h[1] + b) >>> 0;
|
||||
h[2] = (h[2] + c) >>> 0;
|
||||
h[3] = (h[3] + d) >>> 0;
|
||||
h[4] = (h[4] + e) >>> 0;
|
||||
h[5] = (h[5] + f) >>> 0;
|
||||
h[6] = (h[6] + g) >>> 0;
|
||||
h[7] = (h[7] + hh) >>> 0;
|
||||
}
|
||||
|
||||
const digest = new Uint8Array(32);
|
||||
const digestView = new DataView(digest.buffer);
|
||||
h.forEach((value, index) => digestView.setUint32(index * 4, value, false));
|
||||
return digest;
|
||||
}
|
||||
|
||||
export async function createCodeChallenge(codeVerifier: string): Promise<string> {
|
||||
const subtle = isBrowser() ? window.crypto?.subtle : globalThis.crypto?.subtle;
|
||||
if (subtle) {
|
||||
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
|
||||
return base64Url(new Uint8Array(digest));
|
||||
}
|
||||
return base64Url(sha256Fallback(codeVerifier));
|
||||
}
|
||||
|
||||
function randomUrlSafe(bytes = 32): string {
|
||||
const random = new Uint8Array(bytes);
|
||||
const cryptoApi = isBrowser() ? window.crypto : globalThis.crypto;
|
||||
if (cryptoApi?.getRandomValues) {
|
||||
cryptoApi.getRandomValues(random);
|
||||
} else {
|
||||
for (let i = 0; i < random.length; i += 1) {
|
||||
random[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
return base64Url(random);
|
||||
}
|
||||
|
||||
export function saveLoginState(state: KeycloakLoginState): void {
|
||||
if (!isBrowser()) return;
|
||||
sessionStorage.setItem(KEYCLOAK_LOGIN_STATE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function loadLoginState(): KeycloakLoginState | null {
|
||||
if (!isBrowser()) return null;
|
||||
const raw = sessionStorage.getItem(KEYCLOAK_LOGIN_STATE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<KeycloakLoginState>;
|
||||
if (!parsed.state || !parsed.nonce || !parsed.codeVerifier || !parsed.redirectUri) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
state: parsed.state,
|
||||
nonce: parsed.nonce,
|
||||
codeVerifier: parsed.codeVerifier,
|
||||
redirectUri: parsed.redirectUri,
|
||||
nextPath: parsed.nextPath || '/',
|
||||
createdAt: Number(parsed.createdAt || Date.now()),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLoginState(): void {
|
||||
if (!isBrowser()) return;
|
||||
sessionStorage.removeItem(KEYCLOAK_LOGIN_STATE_KEY);
|
||||
}
|
||||
|
||||
export function markKeycloakLogoutInProgress(): void {
|
||||
if (!isBrowser()) return;
|
||||
sessionStorage.setItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY, String(Date.now()));
|
||||
}
|
||||
|
||||
export function clearKeycloakLogoutInProgress(): void {
|
||||
if (!isBrowser()) return;
|
||||
sessionStorage.removeItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY);
|
||||
}
|
||||
|
||||
export function isKeycloakLogoutInProgress(): boolean {
|
||||
if (!isBrowser()) return false;
|
||||
const raw = sessionStorage.getItem(KEYCLOAK_LOGOUT_IN_PROGRESS_KEY);
|
||||
if (!raw) return false;
|
||||
const timestamp = Number(raw);
|
||||
if (!Number.isFinite(timestamp) || Date.now() - timestamp > LOGOUT_PROGRESS_TTL_MS) {
|
||||
clearKeycloakLogoutInProgress();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function startKeycloakLogin(nextPath = '/'): Promise<void> {
|
||||
if (!isBrowser()) return;
|
||||
clearKeycloakLogoutInProgress();
|
||||
const codeVerifier = randomUrlSafe(64);
|
||||
const state = randomUrlSafe(32);
|
||||
const nonce = randomUrlSafe(32);
|
||||
const codeChallenge = await createCodeChallenge(codeVerifier);
|
||||
const loginState: KeycloakLoginState = {
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
redirectUri: getKeycloakRedirectUri(),
|
||||
nextPath,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
saveLoginState(loginState);
|
||||
window.location.assign(buildKeycloakAuthorizeUrl({ state, nonce, codeChallenge, nextPath }));
|
||||
}
|
||||
|
||||
export function parseAuthCallbackUrl(): KeycloakCallbackParams {
|
||||
const params = isBrowser() ? new URLSearchParams(window.location.search) : new URLSearchParams();
|
||||
return {
|
||||
code: params.get('code') || '',
|
||||
state: params.get('state') || '',
|
||||
error: params.get('error') || '',
|
||||
errorDescription: params.get('error_description') || '',
|
||||
};
|
||||
}
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
const configured = envValue('NEXT_PUBLIC_API_URL');
|
||||
if (configured) return configured.replace(/\/+$/, '');
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function exchangeKeycloakCallback(input: CallbackExchangeInput): Promise<CallbackExchangeResult> {
|
||||
const response = await fetch(`${getApiBaseUrl()}/api/auth/callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: input.code,
|
||||
state: input.state.state,
|
||||
code_verifier: input.state.codeVerifier,
|
||||
redirect_uri: input.state.redirectUri,
|
||||
nonce: input.state.nonce,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Keycloak callback failed: ${response.status} ${text}`);
|
||||
}
|
||||
const token = await response.json() as TokenResponse;
|
||||
setTokens(token.access_token, token.refresh_token || '', token.id_token || '');
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: token.user_id,
|
||||
username: token.username,
|
||||
email: token.email || '',
|
||||
role: token.role || 'owner',
|
||||
quota_tier: 'single-user',
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -22,12 +22,13 @@ export interface BackendConnectionInfo {
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
id_token?: string;
|
||||
expires_in?: number;
|
||||
token_type: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
handoff_code?: string;
|
||||
handoff_expires_at?: number;
|
||||
backend_connection?: BackendConnectionInfo | null;
|
||||
local_backend?: AuthzLocalBackendStatus | null;
|
||||
}
|
||||
|
||||
@ -1,347 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_REGISTRY = Path(__file__).resolve().parent / "runtime" / "registry" / "instances.json"
|
||||
|
||||
|
||||
def _default_data() -> dict[str, Any]:
|
||||
return {"version": 1, "instances": []}
|
||||
|
||||
|
||||
def _normalize_record(record: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = dict(record)
|
||||
normalized["host_port"] = int(record.get("host_port", 0) or 0)
|
||||
for key in (
|
||||
"instance_id",
|
||||
"instance_slug",
|
||||
"container_name",
|
||||
"image_name",
|
||||
"public_url",
|
||||
"instance_root",
|
||||
"beaver_home",
|
||||
"config_path",
|
||||
"auth_users_path",
|
||||
"network_name",
|
||||
"backend_id",
|
||||
"backend_name",
|
||||
"authz_base_url",
|
||||
"created_at",
|
||||
"username",
|
||||
"email",
|
||||
"instance_host",
|
||||
"frontend_base_url",
|
||||
"api_base_url",
|
||||
):
|
||||
normalized[key] = str(record.get(key, "") or "")
|
||||
return normalized
|
||||
|
||||
|
||||
def read_registry(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return _default_data()
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return _default_data()
|
||||
if not isinstance(data, dict):
|
||||
return _default_data()
|
||||
if not isinstance(data.get("instances"), list):
|
||||
data["instances"] = []
|
||||
data["instances"] = [_normalize_record(item) for item in data["instances"] if isinstance(item, dict)]
|
||||
return data
|
||||
|
||||
|
||||
@contextmanager
|
||||
def locked_registry(path: Path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = path.with_suffix(".lock")
|
||||
lock_path.touch(exist_ok=True)
|
||||
with lock_path.open("r+", encoding="utf-8") as lock_file:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||
if path.exists():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
data = _default_data()
|
||||
else:
|
||||
data = _default_data()
|
||||
if not isinstance(data, dict):
|
||||
data = _default_data()
|
||||
if not isinstance(data.get("instances"), list):
|
||||
data["instances"] = []
|
||||
data["instances"] = [_normalize_record(item) for item in data["instances"] if isinstance(item, dict)]
|
||||
try:
|
||||
yield data
|
||||
finally:
|
||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _match(
|
||||
record: dict[str, Any],
|
||||
*,
|
||||
instance_id: str | None,
|
||||
slug: str | None,
|
||||
container_name: str | None,
|
||||
username: str | None,
|
||||
instance_host: str | None,
|
||||
) -> bool:
|
||||
if instance_id and record.get("instance_id") == instance_id:
|
||||
return True
|
||||
if slug and record.get("instance_slug") == slug:
|
||||
return True
|
||||
if container_name and record.get("container_name") == container_name:
|
||||
return True
|
||||
if username and record.get("username") == username:
|
||||
return True
|
||||
if instance_host and record.get("instance_host") == instance_host:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_record(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
instance_id: str | None,
|
||||
slug: str | None,
|
||||
container_name: str | None,
|
||||
username: str | None,
|
||||
instance_host: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
for item in data["instances"]:
|
||||
if _match(
|
||||
item,
|
||||
instance_id=instance_id,
|
||||
slug=slug,
|
||||
container_name=container_name,
|
||||
username=username,
|
||||
instance_host=instance_host,
|
||||
):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
path = Path(args.registry).expanduser()
|
||||
data = read_registry(path)
|
||||
instances = list(data["instances"])
|
||||
if args.json:
|
||||
json.dump({"instances": instances}, sys.stdout, indent=2, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
for item in instances:
|
||||
print(
|
||||
"\t".join(
|
||||
[
|
||||
str(item.get("instance_id", "")),
|
||||
str(item.get("instance_slug", "")),
|
||||
str(item.get("container_name", "")),
|
||||
str(item.get("host_port", "")),
|
||||
str(item.get("public_url", "")),
|
||||
str(item.get("instance_root", "")),
|
||||
]
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_get(args: argparse.Namespace) -> int:
|
||||
path = Path(args.registry).expanduser()
|
||||
data = read_registry(path)
|
||||
record = _get_record(
|
||||
data,
|
||||
instance_id=args.instance_id,
|
||||
slug=args.slug,
|
||||
container_name=args.container_name,
|
||||
username=args.username,
|
||||
instance_host=args.instance_host,
|
||||
)
|
||||
if record is None:
|
||||
return 1
|
||||
json.dump(record, sys.stdout, indent=2, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_upsert(args: argparse.Namespace) -> int:
|
||||
path = Path(args.registry).expanduser()
|
||||
record = {
|
||||
"instance_id": args.instance_id,
|
||||
"instance_slug": args.instance_slug,
|
||||
"container_name": args.container_name,
|
||||
"image_name": args.image_name,
|
||||
"host_port": int(args.host_port),
|
||||
"public_url": args.public_url,
|
||||
"instance_root": args.instance_root,
|
||||
"beaver_home": args.beaver_home,
|
||||
"config_path": args.config_path,
|
||||
"auth_users_path": args.auth_users_path,
|
||||
"network_name": args.network_name or "",
|
||||
"backend_id": args.backend_id or "",
|
||||
"backend_name": args.backend_name or "",
|
||||
"authz_base_url": args.authz_base_url or "",
|
||||
"username": args.username or "",
|
||||
"email": args.email or "",
|
||||
"instance_host": args.instance_host or "",
|
||||
"frontend_base_url": args.frontend_base_url or "",
|
||||
"api_base_url": args.api_base_url or "",
|
||||
"created_at": args.created_at,
|
||||
}
|
||||
with locked_registry(path) as data:
|
||||
kept: list[dict[str, Any]] = []
|
||||
for item in data["instances"]:
|
||||
if _match(
|
||||
item,
|
||||
instance_id=args.instance_id,
|
||||
slug=args.instance_slug,
|
||||
container_name=args.container_name,
|
||||
username=args.username,
|
||||
instance_host=args.instance_host,
|
||||
):
|
||||
continue
|
||||
kept.append(item)
|
||||
kept.append(_normalize_record(record))
|
||||
kept.sort(key=lambda item: str(item.get("instance_id", "")))
|
||||
data["instances"] = kept
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_remove(args: argparse.Namespace) -> int:
|
||||
path = Path(args.registry).expanduser()
|
||||
removed = False
|
||||
removed_record: dict[str, Any] | None = None
|
||||
with locked_registry(path) as data:
|
||||
kept: list[dict[str, Any]] = []
|
||||
for item in data["instances"]:
|
||||
if removed:
|
||||
kept.append(item)
|
||||
continue
|
||||
if _match(
|
||||
item,
|
||||
instance_id=args.instance_id,
|
||||
slug=args.slug,
|
||||
container_name=args.container_name,
|
||||
username=args.username,
|
||||
instance_host=args.instance_host,
|
||||
):
|
||||
removed = True
|
||||
removed_record = item
|
||||
continue
|
||||
kept.append(item)
|
||||
data["instances"] = kept
|
||||
if not removed:
|
||||
return 1
|
||||
if args.print_removed and removed_record is not None:
|
||||
json.dump(removed_record, sys.stdout, indent=2, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_next_port(args: argparse.Namespace) -> int:
|
||||
path = Path(args.registry).expanduser()
|
||||
with locked_registry(path) as data:
|
||||
reserved = {
|
||||
int(item.get("host_port", 0))
|
||||
for item in data["instances"]
|
||||
if int(item.get("host_port", 0) or 0) > 0
|
||||
and item.get("instance_id") != (args.exclude_instance_id or "")
|
||||
}
|
||||
|
||||
for port in range(args.start, args.end + 1):
|
||||
if port in reserved:
|
||||
continue
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
try:
|
||||
sock.bind((args.host, port))
|
||||
except OSError:
|
||||
continue
|
||||
print(port)
|
||||
return 0
|
||||
print(f"no free port in range {args.start}-{args.end}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Manage app-instance registry.")
|
||||
parser.set_defaults(func=None)
|
||||
parser.add_argument("--registry", default=str(DEFAULT_REGISTRY), help="Registry JSON path.")
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
list_parser = subparsers.add_parser("list", help="List registered instances.")
|
||||
list_parser.add_argument("--json", action="store_true", help="Output JSON.")
|
||||
list_parser.set_defaults(func=cmd_list)
|
||||
|
||||
get_parser = subparsers.add_parser("get", help="Get one registered instance.")
|
||||
get_parser.add_argument("--instance-id")
|
||||
get_parser.add_argument("--slug")
|
||||
get_parser.add_argument("--container-name")
|
||||
get_parser.add_argument("--username")
|
||||
get_parser.add_argument("--instance-host")
|
||||
get_parser.set_defaults(func=cmd_get)
|
||||
|
||||
upsert_parser = subparsers.add_parser("upsert", help="Create or update one registry record.")
|
||||
upsert_parser.add_argument("--instance-id", required=True)
|
||||
upsert_parser.add_argument("--instance-slug", required=True)
|
||||
upsert_parser.add_argument("--container-name", required=True)
|
||||
upsert_parser.add_argument("--image-name", required=True)
|
||||
upsert_parser.add_argument("--host-port", required=True, type=int)
|
||||
upsert_parser.add_argument("--public-url", required=True)
|
||||
upsert_parser.add_argument("--instance-root", required=True)
|
||||
upsert_parser.add_argument("--beaver-home", required=True)
|
||||
upsert_parser.add_argument("--config-path", required=True)
|
||||
upsert_parser.add_argument("--auth-users-path", required=True)
|
||||
upsert_parser.add_argument("--network-name", default="")
|
||||
upsert_parser.add_argument("--backend-id", default="")
|
||||
upsert_parser.add_argument("--backend-name", default="")
|
||||
upsert_parser.add_argument("--authz-base-url", default="")
|
||||
upsert_parser.add_argument("--username", default="")
|
||||
upsert_parser.add_argument("--email", default="")
|
||||
upsert_parser.add_argument("--instance-host", default="")
|
||||
upsert_parser.add_argument("--frontend-base-url", default="")
|
||||
upsert_parser.add_argument("--api-base-url", default="")
|
||||
upsert_parser.add_argument("--created-at", required=True)
|
||||
upsert_parser.set_defaults(func=cmd_upsert)
|
||||
|
||||
remove_parser = subparsers.add_parser("remove", help="Remove one registry record.")
|
||||
remove_parser.add_argument("--instance-id")
|
||||
remove_parser.add_argument("--slug")
|
||||
remove_parser.add_argument("--container-name")
|
||||
remove_parser.add_argument("--username")
|
||||
remove_parser.add_argument("--instance-host")
|
||||
remove_parser.add_argument("--print-removed", action="store_true")
|
||||
remove_parser.set_defaults(func=cmd_remove)
|
||||
|
||||
port_parser = subparsers.add_parser("next-port", help="Find next free host port.")
|
||||
port_parser.add_argument("--host", default="127.0.0.1")
|
||||
port_parser.add_argument("--start", default=20000, type=int)
|
||||
port_parser.add_argument("--end", default=29999, type=int)
|
||||
port_parser.add_argument("--exclude-instance-id", default="")
|
||||
port_parser.set_defaults(func=cmd_next_port)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
if args.func is None:
|
||||
parser.print_help()
|
||||
return 1
|
||||
return int(args.func(args) or 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1,108 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_TOOL="${SCRIPT_DIR}/instance-registry.py"
|
||||
REGISTRY_PATH_DEFAULT="${SCRIPT_DIR}/runtime/registry/instances.json"
|
||||
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
||||
JSON_OUTPUT=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./list-instances.sh [--json] [--registry <path>]
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
shift
|
||||
;;
|
||||
--registry)
|
||||
REGISTRY_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf '[list-instances] unknown argument: %s\n' "$1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||||
python3 - <<'PY' "$REGISTRY_TOOL" "$REGISTRY_PATH"
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
tool = sys.argv[1]
|
||||
registry = sys.argv[2]
|
||||
raw = subprocess.check_output([tool, "--registry", registry, "list", "--json"], text=True)
|
||||
data = json.loads(raw)
|
||||
|
||||
for item in data.get("instances", []):
|
||||
container = item.get("container_name", "")
|
||||
try:
|
||||
status = subprocess.check_output(
|
||||
["docker", "inspect", "-f", "{{.State.Status}}", container],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
status = "missing"
|
||||
item["docker_status"] = status
|
||||
|
||||
json.dump(data, sys.stdout, indent=2, ensure_ascii=False)
|
||||
sys.stdout.write("\n")
|
||||
PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - <<'PY' "$REGISTRY_TOOL" "$REGISTRY_PATH"
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
tool = sys.argv[1]
|
||||
registry = sys.argv[2]
|
||||
raw = subprocess.check_output([tool, "--registry", registry, "list", "--json"], text=True)
|
||||
data = json.loads(raw)
|
||||
items = data.get("instances", [])
|
||||
|
||||
headers = ["INSTANCE_ID", "USERNAME", "HOST", "SLUG", "STATUS", "PORT", "CONTAINER", "PUBLIC_URL"]
|
||||
rows = [headers]
|
||||
|
||||
for item in items:
|
||||
container = item.get("container_name", "")
|
||||
try:
|
||||
status = subprocess.check_output(
|
||||
["docker", "inspect", "-f", "{{.State.Status}}", container],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError:
|
||||
status = "missing"
|
||||
|
||||
rows.append(
|
||||
[
|
||||
str(item.get("instance_id", "")),
|
||||
str(item.get("username", "")),
|
||||
str(item.get("instance_host", "")),
|
||||
str(item.get("instance_slug", "")),
|
||||
status,
|
||||
str(item.get("host_port", "")),
|
||||
container,
|
||||
str(item.get("public_url", "")),
|
||||
]
|
||||
)
|
||||
|
||||
widths = [max(len(row[idx]) for row in rows) for idx in range(len(headers))]
|
||||
for row in rows:
|
||||
print(" ".join(value.ljust(widths[idx]) for idx, value in enumerate(row)))
|
||||
PY
|
||||
@ -1,130 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_TOOL="${SCRIPT_DIR}/instance-registry.py"
|
||||
REGISTRY_PATH_DEFAULT="${SCRIPT_DIR}/runtime/registry/instances.json"
|
||||
|
||||
INSTANCE_ID=""
|
||||
INSTANCE_SLUG=""
|
||||
CONTAINER_NAME=""
|
||||
REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}"
|
||||
PURGE_DATA=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./remove-instance.sh [--instance-id <id> | --slug <slug> | --container-name <name>] [options]
|
||||
|
||||
Options:
|
||||
--instance-id <id> Remove by instance id.
|
||||
--slug <slug> Remove by instance slug.
|
||||
--container-name <name> Remove by container name.
|
||||
--purge-data Delete instance data directory after container removal.
|
||||
--registry <path> Registry JSON path.
|
||||
--help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
printf '[remove-instance] %s\n' "$*"
|
||||
}
|
||||
|
||||
die() {
|
||||
printf '[remove-instance] %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
purge_instance_root() {
|
||||
local instance_root="$1"
|
||||
local image_name="$2"
|
||||
|
||||
[[ -d "$instance_root" ]] || return 0
|
||||
|
||||
docker run --rm \
|
||||
-v "${instance_root}:/target" \
|
||||
--entrypoint /bin/sh \
|
||||
"$image_name" \
|
||||
-c 'rm -rf /target/* /target/.[!.]* /target/..?*' >/dev/null
|
||||
|
||||
rmdir "$instance_root"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--instance-id)
|
||||
INSTANCE_ID="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--slug)
|
||||
INSTANCE_SLUG="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--container-name)
|
||||
CONTAINER_NAME="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--registry)
|
||||
REGISTRY_PATH="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--purge-data)
|
||||
PURGE_DATA=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$INSTANCE_ID$INSTANCE_SLUG$CONTAINER_NAME" ]]; then
|
||||
die "one of --instance-id, --slug or --container-name is required"
|
||||
fi
|
||||
|
||||
REGISTRY_GET_ARGS=(--registry "$REGISTRY_PATH" get)
|
||||
if [[ -n "$INSTANCE_ID" ]]; then
|
||||
REGISTRY_GET_ARGS+=(--instance-id "$INSTANCE_ID")
|
||||
fi
|
||||
if [[ -n "$INSTANCE_SLUG" ]]; then
|
||||
REGISTRY_GET_ARGS+=(--slug "$INSTANCE_SLUG")
|
||||
fi
|
||||
if [[ -n "$CONTAINER_NAME" ]]; then
|
||||
REGISTRY_GET_ARGS+=(--container-name "$CONTAINER_NAME")
|
||||
fi
|
||||
|
||||
if ! RECORD_JSON="$("$REGISTRY_TOOL" "${REGISTRY_GET_ARGS[@]}" 2>/dev/null)"; then
|
||||
die "instance not found in registry"
|
||||
fi
|
||||
|
||||
INSTANCE_ID="$(printf '%s' "$RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["instance_id"])')"
|
||||
INSTANCE_SLUG="$(printf '%s' "$RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["instance_slug"])')"
|
||||
CONTAINER_NAME="$(printf '%s' "$RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["container_name"])')"
|
||||
INSTANCE_ROOT="$(printf '%s' "$RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["instance_root"])')"
|
||||
IMAGE_NAME="$(printf '%s' "$RECORD_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["image_name"])')"
|
||||
|
||||
if docker container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then
|
||||
log "removing container ${CONTAINER_NAME}"
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null
|
||||
else
|
||||
log "container ${CONTAINER_NAME} already absent"
|
||||
fi
|
||||
|
||||
"$REGISTRY_TOOL" --registry "$REGISTRY_PATH" remove --instance-id "$INSTANCE_ID" >/dev/null
|
||||
|
||||
if [[ "$PURGE_DATA" -eq 1 ]]; then
|
||||
log "purging instance data ${INSTANCE_ROOT}"
|
||||
purge_instance_root "$INSTANCE_ROOT" "$IMAGE_NAME"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
instance_id=${INSTANCE_ID}
|
||||
instance_slug=${INSTANCE_SLUG}
|
||||
container_name=${CONTAINER_NAME}
|
||||
instance_root=${INSTANCE_ROOT}
|
||||
purged_data=${PURGE_DATA}
|
||||
EOF
|
||||
155
app-instance/run-standalone.sh
Executable file
155
app-instance/run-standalone.sh
Executable file
@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/.env}"
|
||||
BUILD=0
|
||||
REPLACE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--env-file)
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--build)
|
||||
BUILD=1
|
||||
shift
|
||||
;;
|
||||
--replace)
|
||||
REPLACE=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
IMAGE_NAME="${IMAGE_NAME:-beaver/app-instance:keycloak}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-beaver-app-instance}"
|
||||
HOST_IP="${HOST_IP:-172.19.0.245}"
|
||||
HOST_PORT="${HOST_PORT:-18080}"
|
||||
HOST_BIND_IP="${HOST_BIND_IP:-0.0.0.0}"
|
||||
DATA_DIR="${DATA_DIR:-runtime/standalone}"
|
||||
DOCKER_NETWORK="${DOCKER_NETWORK:-${NETWORK_NAME:-}}"
|
||||
PUBLIC_URL="${PUBLIC_URL:-http://${HOST_IP}:${HOST_PORT}}"
|
||||
KEYCLOAK_ISSUER="${KEYCLOAK_ISSUER:-https://keycloak.bwgdi.com/realms/beaver}"
|
||||
KEYCLOAK_CLIENT_ID="${KEYCLOAK_CLIENT_ID:-beaver-agnet}"
|
||||
KEYCLOAK_REDIRECT_URI="${KEYCLOAK_REDIRECT_URI:-${PUBLIC_URL}/auth/callback}"
|
||||
KEYCLOAK_POST_LOGOUT_REDIRECT_URI="${KEYCLOAK_POST_LOGOUT_REDIRECT_URI:-${PUBLIC_URL}/logout/callback}"
|
||||
BEAVER_PROVIDER="${BEAVER_PROVIDER:-${PROVIDER:-custom}}"
|
||||
BEAVER_MODEL="${BEAVER_MODEL:-${MODEL:-}}"
|
||||
BEAVER_API_KEY="${BEAVER_API_KEY:-${API_KEY:-}}"
|
||||
BEAVER_API_BASE="${BEAVER_API_BASE:-${API_BASE:-}}"
|
||||
|
||||
if [[ "$DATA_DIR" = /* ]]; then
|
||||
DATA_ROOT="$DATA_DIR"
|
||||
else
|
||||
DATA_ROOT="$SCRIPT_DIR/$DATA_DIR"
|
||||
fi
|
||||
if [[ "$(basename "$DATA_ROOT")" == "beaver-home" ]]; then
|
||||
BEAVER_HOME_HOST="$DATA_ROOT"
|
||||
else
|
||||
BEAVER_HOME_HOST="$DATA_ROOT/beaver-home"
|
||||
fi
|
||||
CONFIG_PATH="$BEAVER_HOME_HOST/config.json"
|
||||
|
||||
mkdir -p "$BEAVER_HOME_HOST/workspace"
|
||||
|
||||
if [[ "${SKIP_PROVIDER_CONFIG:-0}" == "1" && -f "$CONFIG_PATH" ]]; then
|
||||
echo "Keeping existing Beaver config: $CONFIG_PATH"
|
||||
elif [[ ! -f "$CONFIG_PATH" ]]; then
|
||||
cat > "$CONFIG_PATH" <<JSON
|
||||
{
|
||||
"defaultProvider": "${BEAVER_PROVIDER}",
|
||||
"defaultModel": "${BEAVER_MODEL}",
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "${BEAVER_API_KEY}",
|
||||
"apiBase": "${BEAVER_API_BASE}",
|
||||
"models": ["${BEAVER_MODEL}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
elif [[ -w "$CONFIG_PATH" ]]; then
|
||||
tmp_config="$(mktemp)"
|
||||
cat > "$tmp_config" <<JSON
|
||||
{
|
||||
"defaultProvider": "${BEAVER_PROVIDER}",
|
||||
"defaultModel": "${BEAVER_MODEL}",
|
||||
"providers": {
|
||||
"custom": {
|
||||
"apiKey": "${BEAVER_API_KEY}",
|
||||
"apiBase": "${BEAVER_API_BASE}",
|
||||
"models": ["${BEAVER_MODEL}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
mv "$tmp_config" "$CONFIG_PATH"
|
||||
else
|
||||
echo "Config is not writable, keeping existing: $CONFIG_PATH" >&2
|
||||
fi
|
||||
|
||||
if [[ "$BUILD" == "1" ]]; then
|
||||
docker build \
|
||||
--build-arg "NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmmirror.com}" \
|
||||
--build-arg "NPM_FETCH_RETRIES=${NPM_FETCH_RETRIES:-5}" \
|
||||
--build-arg "NPM_FETCH_RETRY_MIN_TIMEOUT=${NPM_FETCH_RETRY_MIN_TIMEOUT:-20000}" \
|
||||
--build-arg "NPM_FETCH_RETRY_MAX_TIMEOUT=${NPM_FETCH_RETRY_MAX_TIMEOUT:-120000}" \
|
||||
--build-arg "APT_MIRROR=${APT_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn/debian}" \
|
||||
--build-arg "PYPI_INDEX_URL=${PYPI_INDEX_URL:-https://pypi.tuna.tsinghua.edu.cn/simple}" \
|
||||
--build-arg "NEXT_PUBLIC_KEYCLOAK_ISSUER=$KEYCLOAK_ISSUER" \
|
||||
--build-arg "NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=$KEYCLOAK_CLIENT_ID" \
|
||||
--build-arg "NEXT_PUBLIC_KEYCLOAK_REDIRECT_URI=$KEYCLOAK_REDIRECT_URI" \
|
||||
--build-arg "NEXT_PUBLIC_KEYCLOAK_POST_LOGOUT_REDIRECT_URI=$KEYCLOAK_POST_LOGOUT_REDIRECT_URI" \
|
||||
-t "$IMAGE_NAME" "$SCRIPT_DIR"
|
||||
fi
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then
|
||||
if [[ "$REPLACE" == "1" ]]; then
|
||||
docker rm -f "$CONTAINER_NAME" >/dev/null
|
||||
else
|
||||
echo "Container already exists: $CONTAINER_NAME. Use --replace to recreate it." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
docker_args=(
|
||||
run -d
|
||||
--name "$CONTAINER_NAME"
|
||||
-p "${HOST_BIND_IP}:${HOST_PORT}:8080"
|
||||
-v "$BEAVER_HOME_HOST:/root/.beaver"
|
||||
-e "BEAVER_KEYCLOAK_ISSUER=$KEYCLOAK_ISSUER"
|
||||
-e "BEAVER_KEYCLOAK_CLIENT_ID=$KEYCLOAK_CLIENT_ID"
|
||||
-e "BEAVER_FRONTEND_PUBLIC_BASE_URL=$PUBLIC_URL"
|
||||
-e "BEAVER_BACKEND_IDENTITY__PUBLIC_BASE_URL=$PUBLIC_URL"
|
||||
-e "BEAVER_USER_FILES_MAX_UPLOAD_BYTES=${USER_FILES_MAX_UPLOAD_BYTES:-5368709120}"
|
||||
)
|
||||
|
||||
if [[ -n "${DOCKER_NETWORK:-}" ]]; then
|
||||
docker_args+=(--network "$DOCKER_NETWORK")
|
||||
fi
|
||||
|
||||
docker_args+=("$IMAGE_NAME")
|
||||
docker "${docker_args[@]}" >/dev/null
|
||||
|
||||
cat <<EOF
|
||||
public_url: $PUBLIC_URL
|
||||
redirect_uri: $KEYCLOAK_REDIRECT_URI
|
||||
post_logout_redirect_uri: $KEYCLOAK_POST_LOGOUT_REDIRECT_URI
|
||||
web_origin: $PUBLIC_URL
|
||||
container_name: $CONTAINER_NAME
|
||||
data_dir: $BEAVER_HOME_HOST
|
||||
config_path: $CONFIG_PATH
|
||||
EOF
|
||||
40
app-instance/tests/test_standalone_deploy.py
Normal file
40
app-instance/tests/test_standalone_deploy.py
Normal file
@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
APP_INSTANCE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_standalone_runner_uses_plain_ip_keycloak_redirects() -> None:
|
||||
script = (APP_INSTANCE / "run-standalone.sh").read_text(encoding="utf-8")
|
||||
|
||||
assert 'PUBLIC_URL="${PUBLIC_URL:-http://${HOST_IP}:${HOST_PORT}}"' in script
|
||||
assert "/auth/callback" in script
|
||||
assert "/logout/callback" in script
|
||||
assert "nip.io" not in script
|
||||
assert "instance-registry" not in script
|
||||
assert "router-proxy" not in script
|
||||
|
||||
|
||||
def test_env_example_contains_dockerfile_and_keycloak_settings() -> None:
|
||||
env = (APP_INSTANCE / ".env.example").read_text(encoding="utf-8")
|
||||
|
||||
assert "HOST_IP=172.19.0.245" in env
|
||||
assert "KEYCLOAK_ISSUER=https://keycloak.bwgdi.com/realms/beaver" in env
|
||||
assert "KEYCLOAK_CLIENT_ID=beaver-agnet" in env
|
||||
assert "KEYCLOAK_POST_LOGOUT_REDIRECT_URI=" in env
|
||||
assert "NPM_REGISTRY=" in env
|
||||
assert "APT_MIRROR=" in env
|
||||
assert "PYPI_INDEX_URL=" in env
|
||||
|
||||
|
||||
def test_app_instance_readme_describes_single_container_contract() -> None:
|
||||
readme = (APP_INSTANCE / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "单体 app instance" in readme
|
||||
assert "redirect_uri" in readme
|
||||
assert "web_origin" in readme
|
||||
assert "post_logout_redirect_uri" in readme
|
||||
assert "nip.io" not in readme
|
||||
assert "每个用户一个实例" not in readme
|
||||
Reference in New Issue
Block a user