From e4bca9765c3e602670d251734a179701091bcd79 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 14:21:31 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(web):=20=E7=A7=8D=E5=AD=90=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=BB=98=E8=AE=A4=E9=94=81=E5=AE=9A=EF=BC=8C=E7=A9=BA?= =?UTF-8?q?=E6=97=B6=E5=8C=BA=E8=B7=9F=E9=9A=8F=20SOCKS5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 tip(#608+#611)干净重做 #612 拆分:解锁/播种确认流;时区与语言 en/cn/jp 选项; 跟随当前 SOCKS5 经 timezone_follow_proxy 写入,固定时区经 timezone,不经 extra_env。 不含 egress/migration/auto_compact/thread-betas/VERSION。 Co-authored-by: ted --- CHANGELOG.md | 1 + docs/PANEL_API.md | 2 +- src/lib/admin/panel-routes.mjs | 52 +++++ test/unit/panel-vm-seed-settings.test.mjs | 56 ++++- web/src/features/vm/detail-page.tsx | 13 +- web/src/features/vm/seed-policy-card.test.ts | 3 + web/src/features/vm/seed-policy-card.tsx | 208 +++++++++++++++++-- web/src/features/vm/seed-settings.test.ts | 26 +++ web/src/features/vm/seed-settings.ts | 46 +++- 9 files changed, 385 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b4ae951..8d7dbbea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 未发布 +- 槽位种子页默认锁定:需确认解锁后才能改配置,播种前再次确认;时区/语言提供 en、cn、jp 基础选项。时区跟随当前 SOCKS5(空/非手动钉住)时展示出口时区,播种经 `timezone_follow_proxy` 写入;可改固定 IANA 时区或改回跟随。TZ/LANG/LC_ALL 仍不经 `extra_env` 覆盖契约键。 - 槽位种子页:`settings.json` 的 env 行与 `theme` 可直接编辑并随播种写入;`autoUpdates`、`grove_enabled` 只读且播种时固定为 `false`;TZ/LANG/LC_ALL、遥测 kill-switch、Bedrock/Vertex、gateway hint 等同理只读(播种由契约重写)。缺 `CLAUDE_CODE_ATTRIBUTION_HEADER` 时默认 `0`。调试上传默认关,独立 query 拉最近 40 条上传,切换开关不重置未保存编辑;Go sidecar 写 `vms//run/telemetry-upload.log`,面板 `GET seed-settings?debug=1` 返回 `telemetry_log`。 - 槽位种子页按当前槽 `settings.json` 列出真实配置;`claude.setting` 可编辑播种;缺失文件返回 `null`、空文件返回空串,不用策略默认冒充槽内文件;遥测开关与 sidecar 进程状态分开展示,保存不因遥测进程刷新失败而 502。 - fleet 遥测刷新在 kin-egress 二进制被替换(含 `(deleted)` 旧进程与脏 pidfile)时,会按 egress 配置扫停全部 helper、等待 `/proc/` 消失后再绑定新二进制;同一 SOCKS 出口的 stop/start 与 `ensureProxyEgress` 共用 per-proxy 锁,避免并发 telemetry 互相 SIGTERM。(#604) diff --git a/docs/PANEL_API.md b/docs/PANEL_API.md index dc8d8ed3..40582920 100644 --- a/docs/PANEL_API.md +++ b/docs/PANEL_API.md @@ -83,7 +83,7 @@ | POST | `/vms/:id/oauth/generate-auth-url` | PKCE 授权链接;无 SOCKS5 拒绝。`{ flavor: "claude_code" }` 为官方 Claude Code 授权页。`{ flavor: "setup_token" }` 为 inference-only PKCE,不启槽内 CLI | | POST | `/vms/:id/oauth/exchange-code` | 粘贴授权码,经槽代理换票。完整 OAuth 才排队初装。flavor 以 session 为准 | | GET/POST | `/vms/:id/official-cc-bootstrap` | 初装进度 / `{ manual:true }` 再跑 | -| GET/PUT | `/vms/:id/seed-settings` | 播种;`settings.json` env 与 `theme` 经 PUT `extra_env` / `theme` 可编辑;`autoUpdates`/`grove_enabled` 播种固定;强制保留 telemetry/bedrock/vertex 等 env;`claude_setting` 可编辑并写入槽内 `.claude/claude.setting`。`GET ?debug=1` 额外返回 `telemetry_log`(最近 40 行脱敏上传审计,默认不带) | +| GET/PUT | `/vms/:id/seed-settings` | 播种;面板默认锁定,解锁后改、播种前确认。`settings.json` 可编辑 env(经 `extra_env`)与 `theme`;`autoUpdates`/`grove_enabled` 与 TZ/LANG/LC_ALL、telemetry/bedrock/vertex 等契约 env 只读展示,TZ/LANG 经 PUT `timezone` / `timezone_follow_proxy` / `locale` 写入槽位后再播种,不经 `extra_env`;`claude_setting` 可编辑。`GET ?debug=1` 额外返回 `telemetry_log`(最近 40 行脱敏上传审计,默认不带) | | POST | `/vms/:id/collect-identity` | guest 采集(locale/tz/`guest_machine_id`) | | POST | `/vms/:id/reload` | 重载该槽 worker | | GET | `/wrap-cli` | kernel / wrap 样本 inspect:`ok, dir, kernel_bin, glibc_shim, wrapper, meta, kernel`。`kernel.source` 为 `configured`(仓内 `KIN_KERNEL_BIN` / `bin/kin-kernel`)或 `sample` | diff --git a/src/lib/admin/panel-routes.mjs b/src/lib/admin/panel-routes.mjs index 1a277da9..e0ccec54 100644 --- a/src/lib/admin/panel-routes.mjs +++ b/src/lib/admin/panel-routes.mjs @@ -3575,6 +3575,58 @@ export function createPanelHandler(ctx) { const next = defaultSeedPolicy(merged) if (body.settings_json_override !== undefined) next.settings_json_override = body.settings_json_override if (body.extra_env !== undefined) next.extra_env = body.extra_env || {} + const followProxyTz = body?.timezone_follow_proxy === true || body?.timezoneFollowProxy === true + const hasTimezone = body && Object.prototype.hasOwnProperty.call(body, 'timezone') + const hasLocale = body && Object.prototype.hasOwnProperty.call(body, 'locale') + let nextLocale = null + if (hasLocale) { + const requestedLocale = + body.locale == null || body.locale === '' ? STANDARD_LOCALE : String(body.locale) + if (!SLOT_LOCALES.includes(requestedLocale)) { + return json(res, 400, { + ok: false, + error: { code: 'invalid_locale', message: `语言必须是 ${SLOT_LOCALES.join('、')}` }, + }) + } + nextLocale = requestedLocale + } + if (hasTimezone) { + const zone = validTimezone(body.timezone) + if (!zone) { + return json(res, 400, { + ok: false, + error: { + type: 'invalid_request_error', + code: 'invalid_timezone', + message: '时区必须是有效的 IANA 名称,如 Asia/Tokyo', + param: 'timezone', + }, + }) + } + const pinned = persistVmTimezone(cfg.paths.project, id, zone, { source: 'manual' }) + if (!pinned) return json(res, 404, { ok: false, error: { message: 'vm not found' } }) + Object.assign(vm, pinned) + } else if (followProxyTz) { + const synced = await syncVmTimezoneFromProxy(cfg.paths.project, proxyPool, id, { force: true }) + if (!synced.ok) { + return json(res, 400, { + ok: false, + error: { + type: 'invalid_request_error', + code: synced.reason || 'proxy_timezone_unknown', + message: '未能从代理节点取到时区,请先检测代理地理位置', + }, + }) + } + if (synced.applied) { + const fresh = getVm(cfg.paths.project, id) + if (fresh) Object.assign(vm, fresh) + } else if (synced.timezone) { + vm.timezone = synced.timezone + vm.timezone_source = 'proxy_geo' + } + } + if (nextLocale) vm.locale = nextLocale vm.seed_policy = next vm.updated_at = new Date().toISOString() atomicWriteJson(vmPath, vm, { mode: 0o600 }) diff --git a/test/unit/panel-vm-seed-settings.test.mjs b/test/unit/panel-vm-seed-settings.test.mjs index 1c53c907..f8523f50 100644 --- a/test/unit/panel-vm-seed-settings.test.mjs +++ b/test/unit/panel-vm-seed-settings.test.mjs @@ -20,7 +20,7 @@ function seed(root, id) { ) } -function handler(project) { +function handler(project, { readBody = async () => ({}) } = {}) { const response = {} const handlePanel = createPanelHandler({ cfg: { paths: { project }, vm: { id: null } }, @@ -34,8 +34,19 @@ function handler(project) { response.body = payload return true }, - readBody: async () => ({}), - proxyPool: { unbindVm() {} }, + readBody, + proxyPool: { + unbindVm() {}, + followProxyTimezoneEnabled() { + return true + }, + proxyTimezone() { + return 'Asia/Shanghai' + }, + timezoneForVm() { + return 'Asia/Shanghai' + }, + }, }) return { handlePanel, response } } @@ -74,6 +85,45 @@ test('GET seed-settings returns an empty claude.setting file as empty', async () } }) +test('PUT seed-settings can follow proxy timezone and locale before writing files', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-seed-put-tz-')) + try { + seed(root, 'vm-01') + const vmPath = path.join(root, 'vms', 'vm-01.json') + const vm = JSON.parse(fs.readFileSync(vmPath, 'utf8')) + vm.timezone = 'UTC' + vm.timezone_source = 'manual' + vm.locale = 'en_US.UTF-8' + vm.proxy = { id: 'px-1' } + fs.writeFileSync(vmPath, JSON.stringify(vm)) + const body = { + telemetry_disabled: true, + claude_setting: 'X=1', + extra_env: { CUSTOM: 'yes' }, + theme: 'dark', + timezone_follow_proxy: true, + locale: 'zh_CN.UTF-8', + } + const { handlePanel, response } = handler(root, { + readBody: async () => body, + }) + await handlePanel( + { method: 'PUT', url: '/api/panel/vms/vm-01/seed-settings' }, + {}, + new URL('http://localhost/api/panel/vms/vm-01/seed-settings'), + ) + assert.equal(response.status, 200, JSON.stringify(response.body).slice(0, 400)) + const saved = JSON.parse(fs.readFileSync(vmPath, 'utf8')) + assert.equal(saved.locale, 'zh_CN.UTF-8') + assert.equal(saved.timezone, 'Asia/Shanghai') + assert.equal(response.body.data.settings_json.env.TZ, 'Asia/Shanghai') + assert.equal(response.body.data.settings_json.env.LANG, 'zh_CN.UTF-8') + assert.equal(response.body.data.settings_json.env.CUSTOM, 'yes') + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + test('GET seed-settings returns the upload log only when debug is on', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-seed-log-')) try { diff --git a/web/src/features/vm/detail-page.tsx b/web/src/features/vm/detail-page.tsx index f33fae21..711185f4 100644 --- a/web/src/features/vm/detail-page.tsx +++ b/web/src/features/vm/detail-page.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useMutation, useMutationState, @@ -166,6 +166,14 @@ function VmDetailBody({ const proxy = ((data.proxy as VmProxySnap | undefined) || vm.proxy || {}) as VmProxySnap + const seedSlot = useMemo( + () => ({ + timezone: vm.timezone, + timezone_source: vm.timezone_source, + locale: vm.locale, + }), + [vm.timezone, vm.timezone_source, vm.locale] + ) const test = useVmTestChat(vm) const refreshAll = async () => { await Promise.all([ @@ -241,6 +249,7 @@ function VmDetailBody({ }), onSuccess: async () => { toast.success('已播种') + await refreshAll() await qc.invalidateQueries({ queryKey: ['panel', 'vm', id, 'seed'], }) @@ -583,12 +592,14 @@ function VmDetailBody({ {seed.data ? ( saveSeed.mutate(next)} /> ) : ( diff --git a/web/src/features/vm/seed-policy-card.test.ts b/web/src/features/vm/seed-policy-card.test.ts index 4f281b08..1a67ed5c 100644 --- a/web/src/features/vm/seed-policy-card.test.ts +++ b/web/src/features/vm/seed-policy-card.test.ts @@ -13,6 +13,9 @@ describe('seed save follows the telemetry bit', () => { claude_setting: 'A=1', env: {}, theme: 'dark', + timezoneFollow: true, + timezoneFixed: '', + locale: 'en_US.UTF-8', }) expect(telemetryEnabled(body)).toBe(true) expect(body.disable_nonessential_traffic).toBe(true) diff --git a/web/src/features/vm/seed-policy-card.tsx b/web/src/features/vm/seed-policy-card.tsx index 8780c566..aa0649c9 100644 --- a/web/src/features/vm/seed-policy-card.tsx +++ b/web/src/features/vm/seed-policy-card.tsx @@ -4,10 +4,20 @@ import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' +import { ConfirmDialog } from '@/components/confirm-dialog' import { REJECT_FLAGS, + SEED_LOCALE_OPTIONS, + SEED_TIMEZONE_OPTIONS, claudeSettingKeys, partitionSeedEnvKeys, seedDraftOf, @@ -16,6 +26,7 @@ import { sameDraft, telemetryLive, type SeedDraft, + type SeedSlotContext, } from '@/features/vm/seed-settings' import { telemetryEnabled } from '@/features/vm/telemetry-policy' @@ -43,6 +54,107 @@ function Lamp({ tone }: { tone: 'on' | 'off' | 'unknown' }) { ) } + +function namedChoice( + table: readonly (readonly [string, string])[], + value: string +) { + return ( + table.find(([key, zone]) => key === value || zone === value)?.[0] || + 'custom' + ) +} + +function ChoiceField({ + label, + table, + value, + disabled, + onChange, +}: { + label: string + table: readonly (readonly [string, string])[] + value: string + disabled: boolean + onChange: (value: string) => void +}) { + const selected = namedChoice(table, value) + return ( + + ) +} + +function LocaleField(props: { + value: string + disabled: boolean + onChange: (value: string) => void +}) { + return ( + + ) +} + +function ZoneField({ + follow, + fixed, + proxyZone, + disabled, + onFollow, + onFixed, +}: { + follow: boolean + fixed: string + proxyZone: string + disabled: boolean + onFollow: () => void + onFixed: (value: string) => void +}) { + return ( +
+ + +

播种时由槽位写入,不经 extra_env

+
+ ) +} + function TelemetryUploadLog({ lines }: { lines: unknown }) { const rows = Array.isArray(lines) ? lines.filter( @@ -103,6 +215,7 @@ function ToggleRow({ export function SeedPolicyCard({ seed, + slot, telemetry, onSave, saving, @@ -110,8 +223,10 @@ export function SeedPolicyCard({ debug, onDebug, uploadLog, + proxyTimezone = '', }: { seed: VmSeedSettings + slot: SeedSlotContext telemetry?: ObservedTelemetry | null onSave: (next: Record) => void saving: boolean @@ -119,12 +234,16 @@ export function SeedPolicyCard({ debug: boolean onDebug: (next: boolean) => void uploadLog?: string[] | null + proxyTimezone?: string | null }) { - const saved = seedDraftOf(seed) + const [locked, setLocked] = useState(true) + const [askUnlock, setAskUnlock] = useState(false) + const [askSave, setAskSave] = useState(false) + const saved = seedDraftOf(seed, slot) const [draft, setDraft] = useState(saved) useEffect(() => { - setDraft(seedDraftOf(seed)) - }, [seed]) + setDraft(seedDraftOf(seed, slot)) + }, [seed, slot]) const dirty = !sameDraft(draft, saved) const on = telemetryEnabled(draft) @@ -136,6 +255,7 @@ export function SeedPolicyCard({ const fileMissing = seed.settings_json == null const { editable: editableEnvKeys, readonly: readonlyEnvKeys } = partitionSeedEnvKeys(draft.env) + const editDisabled = locked || saving const patch = (next: Partial) => setDraft((current) => ({ ...current, ...next })) @@ -147,6 +267,7 @@ export function SeedPolicyCard({

当前槽 settings.json

{fileMissing ? '文件未写入' : `${rows.length} 项`} + {locked ? ' · 已锁定' : ' · 已解锁'}

{fileMissing ? ( @@ -178,7 +299,7 @@ export function SeedPolicyCard({ patch({ env: { ...draft.env, [key]: event.target.value } }) @@ -192,7 +313,7 @@ export function SeedPolicyCard({ patch({ theme: event.target.value })} className='h-8 font-mono text-xs' @@ -214,9 +335,34 @@ export function SeedPolicyCard({ )} {key} - - {draft.env[key] === '' ? '空' : draft.env[key]} · 播种时固定 - + {key === 'TZ' ? ( + + patch({ timezoneFollow: true, timezoneFixed: '' }) + } + onFixed={(value) => + patch({ timezoneFollow: false, timezoneFixed: value }) + } + /> + ) : key === 'LANG' ? ( + patch({ locale: value })} + /> + ) : key === 'LC_ALL' ? ( + + {draft.locale} · 随 LANG 播种时固定 + + ) : ( + + {draft.env[key] === '' ? '空' : draft.env[key]} · 播种时固定 + + )} ))} {rows @@ -273,7 +419,7 @@ export function SeedPolicyCard({ : '非必要流量=0,DNT 开。这两项跟着开关走。' } checked={on} - disabled={saving} + disabled={editDisabled} onCheckedChange={(value) => patch({ telemetry_disabled: !value })} /> {REJECT_FLAGS.map(([key, label]) => ( @@ -291,7 +437,7 @@ export function SeedPolicyCard({ : '入站身份会留下来。' } checked={!!draft[key]} - disabled={saving} + disabled={editDisabled} onCheckedChange={(value) => patch({ [key]: value })} /> ))} @@ -304,7 +450,7 @@ export function SeedPolicyCard({