feat: add local provider account connector
This commit is contained in:
@@ -29,6 +29,8 @@ services:
|
||||
CASAN_OPENAI_COMPATIBLE_API_KEY: ${CASAN_OPENAI_COMPATIBLE_API_KEY:-}
|
||||
CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS: ${CASAN_OPENAI_COMPATIBLE_ALLOWED_HOSTS:-}
|
||||
CASAN_PREFLIGHT: ${CASAN_PREFLIGHT:-0}
|
||||
CASAN_AUTH_BRIDGE_URL: http://host.docker.internal:20130
|
||||
CASAN_AUTH_BRIDGE_TOKEN: ${CASAN_AUTH_BRIDGE_TOKEN:-}
|
||||
volumes:
|
||||
- ./.specify:/app/.specify
|
||||
- ./docs/output:/app/docs/output:ro
|
||||
|
||||
@@ -5,9 +5,10 @@ import { SettingsModule } from './settings/settings.module.js';
|
||||
import { KillSwitchModule } from './kill-switch/kill-switch.module.js';
|
||||
import { ApprovalsModule } from './approvals/approvals.module.js';
|
||||
import { ChatModule } from './chat/chat.module.js';
|
||||
import { ProviderAuthModule } from './provider-auth/provider-auth.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule],
|
||||
imports: [TelemetryModule, SettingsModule, KillSwitchModule, ApprovalsModule, ChatModule, ProviderAuthModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get, Headers, Inject, Param, Post } from '@nestjs/common';
|
||||
import { ok } from '../common/api-response.js';
|
||||
import { actorFromHeaders } from '../common/auth-context.js';
|
||||
import { ProviderAuthService } from './provider-auth.service.js';
|
||||
|
||||
@Controller('api/v1/provider-auth')
|
||||
export class ProviderAuthController {
|
||||
constructor(@Inject(ProviderAuthService) private readonly service: ProviderAuthService) {}
|
||||
|
||||
@Get()
|
||||
async status(@Headers() headers: Record<string, string | string[] | undefined>) {
|
||||
return ok(await this.service.status(actorFromHeaders(headers)));
|
||||
}
|
||||
|
||||
@Post(':provider/login')
|
||||
async login(
|
||||
@Param('provider') provider: string,
|
||||
@Headers() headers: Record<string, string | string[] | undefined>,
|
||||
) {
|
||||
return ok(await this.service.login(provider, actorFromHeaders(headers)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProviderAuthController } from './provider-auth.controller.js';
|
||||
import { ProviderAuthService } from './provider-auth.service.js';
|
||||
|
||||
@Module({
|
||||
controllers: [ProviderAuthController],
|
||||
providers: [ProviderAuthService],
|
||||
})
|
||||
export class ProviderAuthModule {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ForbiddenException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type { SettingsActor } from '../settings/settings.service.js';
|
||||
|
||||
export interface ProviderAuthStatus {
|
||||
id: 'codex' | 'claude';
|
||||
label: string;
|
||||
available: boolean;
|
||||
loggedIn: boolean;
|
||||
authenticating: boolean;
|
||||
authMethod: string;
|
||||
}
|
||||
|
||||
interface BridgeStatusResponse {
|
||||
success: boolean;
|
||||
providers: ProviderAuthStatus[];
|
||||
}
|
||||
|
||||
interface BridgeLoginResponse {
|
||||
success: boolean;
|
||||
reason: string;
|
||||
provider: ProviderAuthStatus;
|
||||
}
|
||||
|
||||
const PROVIDERS = new Set(['codex', 'claude']);
|
||||
|
||||
@Injectable()
|
||||
export class ProviderAuthService {
|
||||
private readonly bridgeUrl = (process.env.CASAN_AUTH_BRIDGE_URL || 'http://host.docker.internal:20130').replace(/\/$/, '');
|
||||
private readonly bridgeToken = process.env.CASAN_AUTH_BRIDGE_TOKEN || '';
|
||||
|
||||
async status(actor: SettingsActor): Promise<BridgeStatusResponse> {
|
||||
this.requireRead(actor);
|
||||
return this.bridgeRequest<BridgeStatusResponse>('/v1/auth/providers', 'GET');
|
||||
}
|
||||
|
||||
async login(provider: string, actor: SettingsActor): Promise<BridgeLoginResponse> {
|
||||
this.requireAdmin(actor);
|
||||
if (!PROVIDERS.has(provider)) throw new ForbiddenException('PROVIDER_AUTH_UNKNOWN_PROVIDER');
|
||||
return this.bridgeRequest<BridgeLoginResponse>(`/v1/auth/${provider}/login`, 'POST');
|
||||
}
|
||||
|
||||
private async bridgeRequest<T>(path: string, method: 'GET' | 'POST'): Promise<T> {
|
||||
if (!this.bridgeToken) throw new ServiceUnavailableException('PROVIDER_AUTH_BRIDGE_NOT_CONFIGURED');
|
||||
try {
|
||||
const response = await fetch(`${this.bridgeUrl}${path}`, {
|
||||
method,
|
||||
headers: { 'X-CASAN-Bridge-Token': this.bridgeToken },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const payload = await response.json() as T & { reason?: string };
|
||||
if (!response.ok) throw new ServiceUnavailableException(payload.reason || 'PROVIDER_AUTH_BRIDGE_FAILED');
|
||||
return payload;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ServiceUnavailableException) throw error;
|
||||
throw new ServiceUnavailableException('PROVIDER_AUTH_BRIDGE_UNREACHABLE');
|
||||
}
|
||||
}
|
||||
|
||||
private requireRead(actor: SettingsActor) {
|
||||
if (!['viewer', 'auditor', 'operator', 'project-admin', 'org-admin'].includes(actor.role)) {
|
||||
throw new ForbiddenException('PROVIDER_AUTH_READ_DENIED');
|
||||
}
|
||||
}
|
||||
|
||||
private requireAdmin(actor: SettingsActor) {
|
||||
if (!['project-admin', 'org-admin'].includes(actor.role)) {
|
||||
throw new ForbiddenException('PROVIDER_AUTH_ADMIN_REQUIRED');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, ChatModelConnection, SettingsActor } from '../../lib/api';
|
||||
import { api, ChatModelConnection, ProviderAuthStatus, SettingsActor } from '../../lib/api';
|
||||
|
||||
interface ModelConnectionPanelProps {
|
||||
actor: SettingsActor;
|
||||
@@ -47,6 +47,13 @@ export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelCo
|
||||
enabled: open,
|
||||
retry: false,
|
||||
});
|
||||
const accountQuery = useQuery({
|
||||
queryKey: ['provider-auth', actor],
|
||||
queryFn: () => api.providerAuthStatus(actor),
|
||||
enabled: open,
|
||||
retry: false,
|
||||
refetchInterval: (state) => state.state.data?.providers.some((provider) => provider.authenticating) ? 1500 : false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data?.connections) return;
|
||||
@@ -94,7 +101,16 @@ export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelCo
|
||||
onSuccess: async (result) => finish(`${result.connection.label} đã được ngắt kết nối; secret đã bị xóa.`),
|
||||
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
|
||||
});
|
||||
const busy = connect.isPending || refresh.isPending || setDefault.isPending || disconnect.isPending;
|
||||
const login = useMutation({
|
||||
mutationFn: (provider: ProviderAuthStatus) => api.startProviderLogin(actor, provider.id),
|
||||
onSuccess: async (result) => {
|
||||
setError(null);
|
||||
setNotice(result.reason === 'already_logged_in' ? `${result.provider.label} đã đăng nhập.` : `Đã mở browser login cho ${result.provider.label}. CASAN sẽ tự cập nhật khi callback hoàn tất.`);
|
||||
await accountQuery.refetch();
|
||||
},
|
||||
onError: (reason) => { setNotice(null); setError(messageOf(reason)); },
|
||||
});
|
||||
const busy = connect.isPending || refresh.isPending || setDefault.isPending || disconnect.isPending || login.isPending;
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
@@ -114,6 +130,22 @@ export function ModelConnectionPanel({ actor, open, onClose, onSelect }: ModelCo
|
||||
{notice && <div className="mb-5 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{notice}</div>}
|
||||
{error && <div role="alert" className="mb-5 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>}
|
||||
{query.isLoading && <div className="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center text-sm text-slate-500">Đang đọc kho kết nối đã mã hóa…</div>}
|
||||
<section className="mb-5 rounded-2xl border border-indigo-200 bg-gradient-to-br from-indigo-950 to-slate-900 p-5 text-white shadow-lg shadow-indigo-950/10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div><div className="text-[10px] font-bold uppercase tracking-[0.18em] text-indigo-300">Local account connector</div><h3 className="mt-1 text-base font-semibold">Đăng nhập bằng tài khoản Codex hoặc Claude</h3><p className="mt-1 max-w-2xl text-xs leading-5 text-indigo-100/70">Browser login do CLI chính thức trên máy Mac xử lý. Credential nằm trong Keychain/CLI store và không đi vào Docker hoặc trình duyệt CASAN.</p></div>
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-indigo-100">Host bridge</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{(accountQuery.data?.providers ?? []).map((provider) => (
|
||||
<div key={provider.id} className="rounded-xl border border-white/10 bg-white/5 p-3.5">
|
||||
<div className="flex items-center justify-between gap-2"><div className="text-sm font-semibold">{provider.label}</div><span className={`rounded-full px-2 py-1 text-[9px] font-bold uppercase ${provider.loggedIn ? 'bg-emerald-300/20 text-emerald-200' : provider.authenticating ? 'bg-amber-300/20 text-amber-200' : 'bg-white/10 text-slate-300'}`}>{provider.loggedIn ? 'Logged in' : provider.authenticating ? 'Waiting callback' : provider.available ? 'Signed out' : 'CLI missing'}</span></div>
|
||||
<p className="mt-1 text-[11px] text-indigo-100/60">{provider.loggedIn ? `Auth: ${provider.authMethod}` : provider.available ? 'Login opens in your default browser.' : 'Install the official CLI on the Mac host first.'}</p>
|
||||
<button type="button" onClick={() => login.mutate(provider)} disabled={busy || !provider.available || provider.loggedIn || !['project-admin', 'org-admin'].includes(actor.role)} className="mt-3 rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-slate-900 transition hover:bg-indigo-50 disabled:cursor-not-allowed disabled:bg-white/20 disabled:text-white/50">{provider.loggedIn ? 'Connected' : provider.authenticating ? 'Waiting…' : `Login ${provider.id === 'codex' ? 'with ChatGPT' : 'with Claude'}`}</button>
|
||||
</div>
|
||||
))}
|
||||
{accountQuery.isError && <div className="rounded-xl border border-rose-300/20 bg-rose-300/10 p-3 text-xs text-rose-100 sm:col-span-2">Host auth bridge chưa sẵn sàng. Chạy lại local-full để bật connector.</div>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(query.data?.connections ?? []).map((connection) => {
|
||||
const form = forms[connection.id] ?? { endpoint: connection.endpoint || DEFAULT_ENDPOINTS[connection.id], apiKey: '' };
|
||||
|
||||
@@ -208,6 +208,15 @@ export interface ChatModelConnection {
|
||||
requiresKey: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAuthStatus {
|
||||
id: 'codex' | 'claude';
|
||||
label: string;
|
||||
available: boolean;
|
||||
loggedIn: boolean;
|
||||
authenticating: boolean;
|
||||
authMethod: string;
|
||||
}
|
||||
|
||||
export interface ChatReplay {
|
||||
ok: boolean;
|
||||
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
|
||||
@@ -347,6 +356,10 @@ export const api = {
|
||||
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/default', { provider, model }, actorHeaders(actor)),
|
||||
disconnectChatProvider: (actor: SettingsActor, provider: string) =>
|
||||
post<{ success: boolean; connection: ChatModelConnection }>('chat/connections/disconnect', { provider }, actorHeaders(actor)),
|
||||
providerAuthStatus: (actor: SettingsActor) =>
|
||||
getWithHeaders<{ success: boolean; providers: ProviderAuthStatus[] }>('provider-auth', actorHeaders(actor)),
|
||||
startProviderLogin: (actor: SettingsActor, provider: ProviderAuthStatus['id']) =>
|
||||
post<{ success: boolean; reason: string; provider: ProviderAuthStatus }>(`provider-auth/${provider}/login`, {}, actorHeaders(actor)),
|
||||
};
|
||||
|
||||
// Health is raw (not enveloped) + carries HTTP status.
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local-only bridge between CASAN Control Panel and official provider CLIs.
|
||||
|
||||
The bridge never reads credential files. It asks the installed Codex/Claude
|
||||
CLI for a sanitized status and can launch their official browser login flow.
|
||||
Only fixed commands are allowed and every request requires a generated bearer
|
||||
token supplied to the backend container by local-full.sh.
|
||||
"""
|
||||
import argparse
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"codex": {
|
||||
"label": "OpenAI Codex",
|
||||
"binary": "codex",
|
||||
"status": ["codex", "login", "status"],
|
||||
"login": ["codex", "login"],
|
||||
},
|
||||
"claude": {
|
||||
"label": "Anthropic Claude Code",
|
||||
"binary": "claude",
|
||||
"status": ["claude", "auth", "status", "--json"],
|
||||
"login": ["claude", "auth", "login", "--claudeai"],
|
||||
},
|
||||
}
|
||||
RUNNING: Dict[str, subprocess.Popen] = {}
|
||||
LOCK = threading.Lock()
|
||||
|
||||
|
||||
def command_status(provider: str) -> dict:
|
||||
config = PROVIDERS[provider]
|
||||
available = shutil.which(config["binary"]) is not None
|
||||
with LOCK:
|
||||
process = RUNNING.get(provider)
|
||||
authenticating = bool(process and process.poll() is None)
|
||||
if process and process.poll() is not None:
|
||||
RUNNING.pop(provider, None)
|
||||
if not available:
|
||||
return {
|
||||
"id": provider,
|
||||
"label": config["label"],
|
||||
"available": False,
|
||||
"loggedIn": False,
|
||||
"authenticating": False,
|
||||
"authMethod": "unavailable",
|
||||
}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
config["status"], capture_output=True, text=True, timeout=8,
|
||||
env={**os.environ, "NO_COLOR": "1"},
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return {
|
||||
"id": provider,
|
||||
"label": config["label"],
|
||||
"available": True,
|
||||
"loggedIn": False,
|
||||
"authenticating": authenticating,
|
||||
"authMethod": "unknown",
|
||||
}
|
||||
if provider == "claude":
|
||||
try:
|
||||
payload = json.loads(result.stdout or "{}")
|
||||
except ValueError:
|
||||
payload = {}
|
||||
logged_in = result.returncode == 0 and bool(payload.get("loggedIn"))
|
||||
method = str(payload.get("authMethod") or "none")
|
||||
else:
|
||||
text = (result.stdout + result.stderr).lower()
|
||||
logged_in = result.returncode == 0 and "logged in" in text
|
||||
method = "chatgpt" if "chatgpt" in text else ("api" if "api" in text and logged_in else "none")
|
||||
return {
|
||||
"id": provider,
|
||||
"label": config["label"],
|
||||
"available": True,
|
||||
"loggedIn": logged_in,
|
||||
"authenticating": authenticating and not logged_in,
|
||||
"authMethod": method,
|
||||
}
|
||||
|
||||
|
||||
def start_login(provider: str) -> dict:
|
||||
status = command_status(provider)
|
||||
if not status["available"]:
|
||||
return {"success": False, "reason": "cli_not_installed", "provider": status}
|
||||
if status["loggedIn"]:
|
||||
return {"success": True, "reason": "already_logged_in", "provider": status}
|
||||
with LOCK:
|
||||
current = RUNNING.get(provider)
|
||||
if current and current.poll() is None:
|
||||
return {"success": True, "reason": "login_in_progress", "provider": {**status, "authenticating": True}}
|
||||
process = subprocess.Popen(
|
||||
PROVIDERS[provider]["login"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
env={**os.environ, "NO_COLOR": "1"},
|
||||
)
|
||||
RUNNING[provider] = process
|
||||
return {"success": True, "reason": "browser_login_started", "provider": command_status(provider)}
|
||||
|
||||
|
||||
class BridgeHandler(BaseHTTPRequestHandler):
|
||||
server_version = "CASANAuthBridge/1.0"
|
||||
|
||||
def log_message(self, _format: str, *_args) -> None:
|
||||
return
|
||||
|
||||
def send_json(self, status: int, payload: dict) -> None:
|
||||
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def authorized(self) -> bool:
|
||||
expected = self.server.bridge_token # type: ignore[attr-defined]
|
||||
supplied = self.headers.get("X-CASAN-Bridge-Token", "")
|
||||
return bool(expected) and hmac.compare_digest(expected, supplied)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/healthz":
|
||||
self.send_json(200, {"status": "ok"})
|
||||
return
|
||||
if not self.authorized():
|
||||
self.send_json(401, {"success": False, "reason": "unauthorized"})
|
||||
return
|
||||
if self.path == "/v1/auth/providers":
|
||||
self.send_json(200, {"success": True, "providers": [command_status(provider) for provider in PROVIDERS]})
|
||||
return
|
||||
self.send_json(404, {"success": False, "reason": "not_found"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if not self.authorized():
|
||||
self.send_json(401, {"success": False, "reason": "unauthorized"})
|
||||
return
|
||||
parts = [part for part in self.path.split("/") if part]
|
||||
if len(parts) == 4 and parts[:2] == ["v1", "auth"] and parts[3] == "login" and parts[2] in PROVIDERS:
|
||||
result = start_login(parts[2])
|
||||
self.send_json(202 if result["success"] else 503, result)
|
||||
return
|
||||
self.send_json(404, {"success": False, "reason": "not_found"})
|
||||
|
||||
|
||||
def read_token(path: str) -> str:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
token = handle.read().strip()
|
||||
except OSError:
|
||||
raise SystemExit("AUTH_BRIDGE_TOKEN_MISSING")
|
||||
if len(token) < 32:
|
||||
raise SystemExit("AUTH_BRIDGE_TOKEN_WEAK")
|
||||
return token
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--bind", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=20130)
|
||||
parser.add_argument("--token-file", required=True)
|
||||
args = parser.parse_args()
|
||||
server = ThreadingHTTPServer((args.bind, args.port), BridgeHandler)
|
||||
server.bridge_token = read_token(args.token_file) # type: ignore[attr-defined]
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -14,9 +14,18 @@ INFRA="$SCRIPT_DIR/infra-lab.sh"
|
||||
CP_COMPOSE="$ROOT/docker-compose.control-panel.local.yml"
|
||||
LOCAL_ENV="$ROOT/infra/local-prod/casan.local.env"
|
||||
TLS_DIR="$ROOT/tmp/control-panel-local/tls"
|
||||
AUTH_BRIDGE_DIR="$ROOT/tmp/control-panel-local/auth-bridge"
|
||||
AUTH_BRIDGE_TOKEN_FILE="$AUTH_BRIDGE_DIR/token"
|
||||
AUTH_BRIDGE_PID_FILE="$AUTH_BRIDGE_DIR/bridge.pid"
|
||||
AUTH_BRIDGE_LOG="$AUTH_BRIDGE_DIR/bridge.log"
|
||||
AUTH_BRIDGE="$ROOT/packages/casan-control-panel/scripts/provider-auth-bridge.py"
|
||||
CMD="${1:-status}"
|
||||
|
||||
cp_compose() {
|
||||
if [[ -f "$AUTH_BRIDGE_TOKEN_FILE" ]]; then
|
||||
export CASAN_AUTH_BRIDGE_TOKEN
|
||||
CASAN_AUTH_BRIDGE_TOKEN="$(tr -d '\r\n' < "$AUTH_BRIDGE_TOKEN_FILE")"
|
||||
fi
|
||||
if [[ -f "$LOCAL_ENV" ]]; then
|
||||
docker compose --env-file "$LOCAL_ENV" -f "$CP_COMPOSE" "$@"
|
||||
else
|
||||
@@ -24,6 +33,31 @@ cp_compose() {
|
||||
fi
|
||||
}
|
||||
|
||||
start_auth_bridge() {
|
||||
mkdir -p "$AUTH_BRIDGE_DIR"
|
||||
if [[ ! -s "$AUTH_BRIDGE_TOKEN_FILE" ]]; then
|
||||
openssl rand -hex 32 > "$AUTH_BRIDGE_TOKEN_FILE"
|
||||
chmod 600 "$AUTH_BRIDGE_TOKEN_FILE"
|
||||
fi
|
||||
if [[ -f "$AUTH_BRIDGE_PID_FILE" ]] && kill -0 "$(cat "$AUTH_BRIDGE_PID_FILE")" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
[[ -f "$AUTH_BRIDGE" ]] || { echo "CASAN_AUTH_BRIDGE_MISSING" >&2; return 1; }
|
||||
nohup python3 "$AUTH_BRIDGE" --bind 0.0.0.0 --port 20130 --token-file "$AUTH_BRIDGE_TOKEN_FILE" > "$AUTH_BRIDGE_LOG" 2>&1 &
|
||||
echo "$!" > "$AUTH_BRIDGE_PID_FILE"
|
||||
chmod 600 "$AUTH_BRIDGE_PID_FILE" "$AUTH_BRIDGE_LOG" 2>/dev/null || true
|
||||
wait_url "http://127.0.0.1:20130/healthz"
|
||||
}
|
||||
|
||||
stop_auth_bridge() {
|
||||
if [[ -f "$AUTH_BRIDGE_PID_FILE" ]]; then
|
||||
local pid
|
||||
pid="$(cat "$AUTH_BRIDGE_PID_FILE")"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
rm -f "$AUTH_BRIDGE_PID_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
need_docker() {
|
||||
command -v docker >/dev/null 2>&1 || { echo "CASAN_LOCAL_DOCKER_MISSING" >&2; exit 1; }
|
||||
docker compose version >/dev/null 2>&1 || { echo "CASAN_LOCAL_COMPOSE_MISSING" >&2; exit 1; }
|
||||
@@ -53,6 +87,7 @@ case "$CMD" in
|
||||
need_docker
|
||||
bash "$INFRA" start
|
||||
ensure_tls
|
||||
start_auth_bridge
|
||||
cp_compose up -d --build
|
||||
wait_url "http://127.0.0.1:18082/healthz"
|
||||
# The unauthenticated console intentionally redirects, so test the OIDC IdP
|
||||
@@ -65,6 +100,7 @@ case "$CMD" in
|
||||
stop)
|
||||
need_docker
|
||||
cp_compose down --remove-orphans
|
||||
stop_auth_bridge
|
||||
bash "$INFRA" stop
|
||||
echo "CASAN_LOCAL_FULL_STOPPED"
|
||||
;;
|
||||
@@ -74,12 +110,18 @@ case "$CMD" in
|
||||
bash "$INFRA" status
|
||||
echo "=== control panel ==="
|
||||
cp_compose ps
|
||||
if [[ -f "$AUTH_BRIDGE_PID_FILE" ]] && kill -0 "$(cat "$AUTH_BRIDGE_PID_FILE")" 2>/dev/null; then
|
||||
echo "provider_auth_bridge=running pid=$(cat "$AUTH_BRIDGE_PID_FILE")"
|
||||
else
|
||||
echo "provider_auth_bridge=stopped"
|
||||
fi
|
||||
;;
|
||||
verify)
|
||||
need_docker
|
||||
bash "$INFRA" verify
|
||||
cp_compose ps
|
||||
wait_url "http://127.0.0.1:18082/healthz"
|
||||
wait_url "http://127.0.0.1:20130/healthz"
|
||||
echo "CASAN_LOCAL_FULL_VERIFY_PASS"
|
||||
;;
|
||||
smoke)
|
||||
|
||||
Reference in New Issue
Block a user