fix: read sandbox binding from backend + harden proxies

This commit is contained in:
Haitao Pan 2026-02-06 18:06:21 +08:00
parent 8f4f4b47bb
commit b346e9430a
10 changed files with 364 additions and 119 deletions

View File

@ -42,18 +42,31 @@ export async function POST(request: NextRequest) {
const contentType = request.headers.get('content-type') ?? 'application/json'
headers.set('Content-Type', contentType)
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, {
method: 'POST',
headers,
body,
cache: 'no-store',
})
try {
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/bind`, {
method: 'POST',
headers,
body,
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await response.text().catch(() => '')
return NextResponse.json(
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
{ status: 502 },
)
}
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
} catch (error) {
console.error('Failed to proxy sandbox bind', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -33,20 +33,33 @@ export async function GET(request: NextRequest) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
}
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
try {
const response = await fetch(`${ACCOUNT_API_BASE}/admin/sandbox/binding`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await response.text().catch(() => '')
return NextResponse.json(
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
{ status: 502 },
)
}
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
} catch (error) {
console.error('Failed to proxy sandbox binding', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
}

View File

@ -0,0 +1,82 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { applySessionCookie } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const ROOT_BACKUP_COOKIE = 'xc_session_root'
type ErrorPayload = {
error: string
}
function secureCookies(): boolean {
if (process.env.NODE_ENV === 'production') {
return true
}
const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ''
return baseUrl.toLowerCase().startsWith('https://')
}
async function verifyRootToken(token: string): Promise<boolean> {
try {
const res = await fetch(`${ACCOUNT_API_BASE}/session`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
if (!res.ok) {
return false
}
const payload = (await res.json().catch(() => null)) as any
const email = typeof payload?.user?.email === 'string' ? payload.user.email.trim().toLowerCase() : ''
return email === 'admin@svc.plus'
} catch {
return false
}
}
export async function POST(request: NextRequest) {
const rootToken = request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? ''
if (!rootToken) {
return NextResponse.json<ErrorPayload>({ error: 'not_assuming' }, { status: 400 })
}
if (!(await verifyRootToken(rootToken))) {
return NextResponse.json<ErrorPayload>({ error: 'root_token_invalid' }, { status: 403 })
}
// Best-effort audit log on accounts.svc.plus. (Cookies are owned by console.)
try {
await fetch(`${ACCOUNT_API_BASE}/admin/assume/revert`, {
method: 'POST',
headers: {
Authorization: `Bearer ${rootToken}`,
Accept: 'application/json',
},
cache: 'no-store',
})
} catch (error) {
console.error('Failed to audit assume revert', error)
}
const response = NextResponse.json({ ok: true })
applySessionCookie(response, rootToken)
response.cookies.set({
name: ROOT_BACKUP_COOKIE,
value: '',
httpOnly: true,
secure: secureCookies(),
sameSite: 'lax',
path: '/',
maxAge: 0,
})
return response
}

View File

@ -0,0 +1,96 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession, userHasRole } from '@server/account/session'
import type { AccountUserRole } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
const REQUIRED_ROLES: AccountUserRole[] = ['admin']
const ROOT_BACKUP_COOKIE = 'xc_session_root'
const SANDBOX_EMAIL = 'sandbox@svc.plus'
type ErrorPayload = {
error: string
}
function isAllowedRootEmail(email?: string): boolean {
return email?.trim().toLowerCase() === 'admin@svc.plus'
}
function secureCookies(): boolean {
if (process.env.NODE_ENV === 'production') {
return true
}
const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ''
return baseUrl.toLowerCase().startsWith('https://')
}
export async function POST(request: NextRequest) {
const session = await getAccountSession(request)
const user = session.user
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
if (!(await userHasRole(user, REQUIRED_ROLES))) {
return NextResponse.json<ErrorPayload>({ error: 'forbidden' }, { status: 403 })
}
if (!isAllowedRootEmail(user.email)) {
return NextResponse.json<ErrorPayload>({ error: 'root_only' }, { status: 403 })
}
try {
const upstream = await fetch(`${ACCOUNT_API_BASE}/admin/assume`, {
method: 'POST',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: SANDBOX_EMAIL }),
cache: 'no-store',
})
const contentType = upstream.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await upstream.text().catch(() => '')
return NextResponse.json(
{ error: 'upstream_non_json', upstreamStatus: upstream.status, upstreamBody: text.slice(0, 2048) } as any,
{ status: 502 },
)
}
const payload = (await upstream.json().catch(() => null)) as any
if (!payload || typeof payload.token !== 'string') {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL })
// Backup current root session token as a host-only cookie (do NOT set domain).
response.cookies.set({
name: ROOT_BACKUP_COOKIE,
value: session.token,
httpOnly: true,
secure: secureCookies(),
sameSite: 'lax',
path: '/',
maxAge: deriveMaxAgeFromExpires(payload.expiresAt),
})
// Switch main session to sandbox token.
applySessionCookie(response, payload.token, deriveMaxAgeFromExpires(payload.expiresAt))
return response
} catch (error) {
console.error('Failed to assume sandbox', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -0,0 +1,11 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
const ROOT_BACKUP_COOKIE = 'xc_session_root'
export async function GET(request: NextRequest) {
const isAssuming = Boolean(request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim())
return NextResponse.json({ isAssuming, target: isAssuming ? 'sandbox@svc.plus' : '' })
}

View File

@ -0,0 +1,49 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
import { getAccountSession } from '@server/account/session'
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
type ErrorPayload = {
error: string
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request)
if (!session.token) {
return NextResponse.json<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/sandbox/binding`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session.token}`,
Accept: 'application/json',
},
cache: 'no-store',
})
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
const text = await response.text().catch(() => '')
return NextResponse.json(
{ error: 'upstream_non_json', upstreamStatus: response.status, upstreamBody: text.slice(0, 2048) } as any,
{ status: 502 },
)
}
const payload = await response.json().catch(() => null)
if (payload === null) {
return NextResponse.json<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
}
return NextResponse.json(payload, { status: response.status })
} catch (error) {
console.error('Failed to proxy sandbox binding (public)', error)
return NextResponse.json<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
}
}

View File

@ -8,7 +8,7 @@ import { Copy } from 'lucide-react'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useUserStore } from '@lib/userStore'
import { getSandboxNodeBinding } from '../lib/sandboxNodeBinding'
import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding'
import Card from './Card'
import VlessQrCard from './VlessQrCard'
@ -127,8 +127,17 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
setSandboxBoundNodeAddress(null)
return
}
const binding = getSandboxNodeBinding()
setSandboxBoundNodeAddress(binding?.address ?? null)
let cancelled = false
void (async () => {
const binding = await fetchSandboxNodeBinding()
if (cancelled) {
return
}
setSandboxBoundNodeAddress(binding?.address ?? null)
})()
return () => {
cancelled = true
}
}, [isGuestSandboxReadOnly])
useEffect(() => {

View File

@ -1,7 +1,5 @@
'use client'
const SANDBOX_NODE_BINDING_KEY = 'xcontrol.sandbox.node.binding.v1'
export type SandboxNodeBinding = {
address: string
name?: string
@ -9,52 +7,29 @@ export type SandboxNodeBinding = {
updatedBy?: string
}
export function getSandboxNodeBinding(): SandboxNodeBinding | null {
if (typeof window === 'undefined') {
return null
}
const raw = window.localStorage.getItem(SANDBOX_NODE_BINDING_KEY)
if (!raw) {
return null
}
export async function fetchSandboxNodeBinding(): Promise<SandboxNodeBinding | null> {
try {
const parsed = JSON.parse(raw) as SandboxNodeBinding
if (typeof parsed?.address === 'string' && parsed.address.trim().length > 0) {
return {
address: parsed.address.trim(),
name: typeof parsed.name === 'string' && parsed.name.trim().length > 0 ? parsed.name.trim() : undefined,
updatedAt: typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(),
updatedBy: typeof parsed.updatedBy === 'string' && parsed.updatedBy.trim().length > 0 ? parsed.updatedBy.trim() : undefined,
}
const response = await fetch('/api/sandbox/binding', { method: 'GET', cache: 'no-store' })
if (!response.ok) {
return null
}
const payload = (await response.json().catch(() => null)) as any
if (!payload || typeof payload.address !== 'string') {
return null
}
const address = payload.address.trim()
if (!address) {
return null
}
return {
address,
name: typeof payload.name === 'string' && payload.name.trim().length > 0 ? payload.name.trim() : undefined,
updatedAt: typeof payload.updatedAt === 'number' ? payload.updatedAt : Date.now(),
updatedBy:
typeof payload.updatedBy === 'string' && payload.updatedBy.trim().length > 0 ? payload.updatedBy.trim() : undefined,
}
} catch (error) {
console.warn('Failed to parse sandbox node binding', error)
console.warn('Failed to fetch sandbox node binding', error)
return null
}
return null
}
export function setSandboxNodeBinding(binding: { address: string; name?: string; updatedBy?: string }) {
if (typeof window === 'undefined') {
return
}
const payload: SandboxNodeBinding = {
address: binding.address.trim(),
name: binding.name?.trim() || undefined,
updatedAt: Date.now(),
updatedBy: binding.updatedBy?.trim() || undefined,
}
window.localStorage.setItem(SANDBOX_NODE_BINDING_KEY, JSON.stringify(payload))
}
export function clearSandboxNodeBinding() {
if (typeof window === 'undefined') {
return
}
window.localStorage.removeItem(SANDBOX_NODE_BINDING_KEY)
}

View File

@ -5,11 +5,6 @@ import useSWR from 'swr'
import Card from '../../components/Card'
import type { VlessNode } from '../../lib/vless'
import {
clearSandboxNodeBinding,
getSandboxNodeBinding,
setSandboxNodeBinding,
} from '../../lib/sandboxNodeBinding'
async function fetcher(url: string): Promise<VlessNode[]> {
const response = await fetch(url, {
@ -44,8 +39,8 @@ export default function SandboxNodeBindingPanel() {
})
const [message, setMessage] = useState<string | null>(null)
const currentBinding = useMemo(() => getSandboxNodeBinding(), [])
const [draftAddress, setDraftAddress] = useState<string>(currentBinding?.address ?? '')
const [activeBinding, setActiveBinding] = useState<{ address: string; updatedAt?: number } | null>(null)
const [draftAddress, setDraftAddress] = useState<string>('')
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
@ -55,23 +50,18 @@ export default function SandboxNodeBindingPanel() {
.then(data => {
if (data && typeof data.address === 'string') {
setDraftAddress(data.address)
if (data.address) {
setSandboxNodeBinding({
address: data.address,
updatedBy: 'server'
})
} else {
clearSandboxNodeBinding()
}
setActiveBinding({
address: data.address,
updatedAt: typeof data.updatedAt === 'number' ? data.updatedAt : undefined,
})
}
})
.catch(err => console.error('Failed to fetch binding from server', err))
}, [])
const isChanged = useMemo(() => {
const current = getSandboxNodeBinding()
return (current?.address ?? '') !== draftAddress
}, [draftAddress])
return (activeBinding?.address ?? '') !== draftAddress
}, [activeBinding?.address, draftAddress])
const handleApply = async (rawAddress: string) => {
const address = rawAddress.trim()
@ -92,15 +82,11 @@ export default function SandboxNodeBindingPanel() {
}
if (!address) {
clearSandboxNodeBinding()
setActiveBinding({ address: '', updatedAt: Date.now() })
setMessage('已成功清空绑定节点 (已同步至服务器)')
} else {
const node = nodes?.find((item) => item.address === address)
setSandboxNodeBinding({
address: address,
name: node?.name || address,
updatedBy: 'root',
})
setActiveBinding({ address, updatedAt: Date.now() })
setMessage(`应用成功:已绑定至 ${node?.name || address} (已同步至服务器)`)
}
@ -112,7 +98,7 @@ export default function SandboxNodeBindingPanel() {
}
}
const currentActive = getSandboxNodeBinding()
const currentActive = activeBinding?.address ? activeBinding : null
return (
<Card>
@ -163,7 +149,7 @@ export default function SandboxNodeBindingPanel() {
{currentActive ? (
<div className="flex items-center gap-2 text-xs text-gray-700">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="font-bold">{currentActive.name || currentActive.address}</span>
<span className="font-bold">{currentActive.address}</span>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-gray-500">
@ -171,11 +157,11 @@ export default function SandboxNodeBindingPanel() {
</div>
)}
{currentActive?.updatedAt && (
{currentActive?.updatedAt ? (
<p className="pl-4 text-[10px] text-gray-400">
{new Date(currentActive.updatedAt).toLocaleString()}
</p>
)}
) : null}
</div>
{error && <p className="text-xs text-red-600"> {error.message}</p>}

View File

@ -8,7 +8,7 @@ import Breadcrumbs from '@/app/panel/components/Breadcrumbs'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import { useUserStore } from '@lib/userStore'
import { getSandboxNodeBinding } from '../lib/sandboxNodeBinding'
import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding'
interface VlessNode {
name: string
@ -67,32 +67,43 @@ export default function UserCenterAgentRoute() {
const isGuestSandboxReadOnly = Boolean(
user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus' || normalizedEmail === 'demo@svc.plus'),
)
const [boundAddress, setBoundAddress] = useState<string | null>(null)
useEffect(() => {
if (!isGuestSandboxReadOnly) {
setBoundNode(null)
setBoundAddress(null)
return
}
const binding = getSandboxNodeBinding()
if (!binding) {
setBoundNode(null)
return
let cancelled = false
void (async () => {
const binding = await fetchSandboxNodeBinding()
if (cancelled) {
return
}
setBoundAddress(binding?.address ?? null)
if (!binding?.address) {
setBoundNode(null)
return
}
setBoundNode({
name: binding.name || 'Sandbox Node',
address: binding.address,
port: 443,
transport: 'tcp',
security: 'tls',
} as any)
})()
return () => {
cancelled = true
}
setBoundNode({
name: binding.name || 'Sandbox Node',
address: binding.address,
port: 443,
transport: 'tcp',
security: 'tls',
} as any)
}, [isGuestSandboxReadOnly])
const effectiveNodes = useMemo(() => {
// 1. If we have a bound node address (from root management), try to find it in the full list
if (isGuestSandboxReadOnly && normalizedEmail) {
const binding = getSandboxNodeBinding()
if (binding?.address && nodes?.length) {
const matched = nodes.find((n) => n.address === binding.address)
if (boundAddress && nodes?.length) {
const matched = nodes.find((n) => n.address === boundAddress)
if (matched) {
return [matched]
}
@ -106,7 +117,7 @@ export default function UserCenterAgentRoute() {
// 3. No fallback logic
return []
}, [isGuestSandboxReadOnly, nodes, visibleNodes, normalizedEmail])
}, [isGuestSandboxReadOnly, nodes, visibleNodes, normalizedEmail, boundAddress])
const groupedNodes = useMemo(() => {
const groups: Record<string, VlessNode[]> = {