commit f27606b7090238711152f98c124e84b5d1b854fa Author: klarkxy <278370456@qq.com> Date: Wed Aug 12 23:25:43 2026 +0800 feat: establish NekoNest Cloud control and relay diff --git a/.github/workflows/cloud-pwa.yml b/.github/workflows/cloud-pwa.yml new file mode 100644 index 0000000..1848eef --- /dev/null +++ b/.github/workflows/cloud-pwa.yml @@ -0,0 +1,114 @@ +name: Build pinned Cloud PWA + +on: + workflow_dispatch: + inputs: + nekonest_commit: + description: Immutable 40-character NekoNest commit SHA + required: true + type: string + connect_origin: + description: Stable HTTPS Connect origin + required: true + type: string + +permissions: + contents: read + +concurrency: + group: cloud-pwa-${{ inputs.nekonest_commit }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Validate immutable inputs + shell: pwsh + env: + NEKONEST_COMMIT: ${{ inputs.nekonest_commit }} + CONNECT_ORIGIN: ${{ inputs.connect_origin }} + run: | + if ($env:NEKONEST_COMMIT -notmatch '^[0-9a-f]{40}$') { + throw 'nekonest_commit must be a full immutable SHA' + } + $origin = [Uri]$env:CONNECT_ORIGIN + if ($origin.Scheme -ne 'https' -or $origin.AbsoluteUri.TrimEnd('/') -ne $env:CONNECT_ORIGIN.TrimEnd('/')) { + throw 'connect_origin must be an exact HTTPS origin' + } + if ($origin.UserInfo -or $origin.PathAndQuery -ne '/' -or $origin.Fragment) { + throw 'connect_origin must not contain credentials, path, query, or fragment' + } + + - name: Check out exact NekoNest source + uses: actions/checkout@v7 + with: + repository: klarkxy/nekonest + ref: ${{ inputs.nekonest_commit }} + fetch-depth: 0 + path: nekonest + + - name: Verify checked-out source did not move + shell: pwsh + working-directory: nekonest + env: + EXPECTED_SHA: ${{ inputs.nekonest_commit }} + run: | + $actual = (git rev-parse HEAD).Trim() + if ($actual -ne $env:EXPECTED_SHA) { throw "expected $env:EXPECTED_SHA, got $actual" } + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.29.2 + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: pnpm + cache-dependency-path: nekonest/pwa/pnpm-lock.yaml + + - name: Install, test, and build managed PWA + working-directory: nekonest/pwa + env: + VITE_NEKONEST_MANAGED: 'true' + run: | + pnpm install --frozen-lockfile + pnpm test + pnpm type-check + pnpm build + + - name: Write deploy-time stable endpoint config + shell: pwsh + env: + CONNECT_ORIGIN: ${{ inputs.connect_origin }} + run: | + $config = [ordered]@{ + api_base = $env:CONNECT_ORIGIN.TrimEnd('/') + ws_base = $env:CONNECT_ORIGIN.TrimEnd('/').Replace('https://', 'wss://') + attachment_base = $env:CONNECT_ORIGIN.TrimEnd('/') + push_base = $env:CONNECT_ORIGIN.TrimEnd('/') + managed = $true + handoff_exchange_path = '/api/pwa/handoff/exchange' + } + $config | ConvertTo-Json -Compress | Set-Content -Encoding utf8NoBOM nekonest/pwa/dist/runtime-config.json + + - name: Record source provenance + shell: pwsh + env: + SOURCE_SHA: ${{ inputs.nekonest_commit }} + run: | + [ordered]@{ + repository = 'https://github.com/klarkxy/nekonest' + commit = $env:SOURCE_SHA + built_at = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress | Set-Content -Encoding utf8NoBOM nekonest/pwa/dist/source-provenance.json + + - name: Upload exact-build artifact + uses: actions/upload-artifact@v7 + with: + name: nekonest-cloud-pwa-${{ inputs.nekonest_commit }} + path: nekonest/pwa/dist + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/cloud-relay.yml b/.github/workflows/cloud-relay.yml new file mode 100644 index 0000000..5939c55 --- /dev/null +++ b/.github/workflows/cloud-relay.yml @@ -0,0 +1,81 @@ +name: Verify pinned Cloud Relay + +on: + workflow_dispatch: + inputs: + relay_core_tag: + description: Exact published Relay Core tag, for example relaycore/v0.1.0 + required: true + type: string + +permissions: + contents: read + +concurrency: + group: cloud-relay-${{ inputs.relay_core_tag }} + cancel-in-progress: false + +jobs: + verify: + runs-on: ubuntu-latest + defaults: + run: + working-directory: relay + env: + GOWORK: 'off' + CGO_ENABLED: '1' + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version-file: relay/go.mod + cache-dependency-path: relay/go.sum + + - name: Require an immutable released Relay Core dependency + shell: pwsh + env: + RELAY_CORE_TAG: ${{ inputs.relay_core_tag }} + run: | + if ($env:RELAY_CORE_TAG -notmatch '^relaycore/v[0-9]+\.[0-9]+\.[0-9]+$') { + throw 'relay_core_tag must be an exact relaycore/vX.Y.Z tag' + } + if (Select-String -Path go.mod -Pattern '^replace\s' -Quiet) { + throw 'Cloud Relay go.mod must not contain replace directives' + } + $expected = $env:RELAY_CORE_TAG.Substring('relaycore/'.Length) + $actual = (go list -m -f '{{.Version}}' github.com/klarkxy/nekonest/relaycore).Trim() + if ($actual -ne $expected) { + throw "go.mod requires $actual but workflow requested $expected" + } + go mod download + + - name: Test, vet, race, and build + run: | + go test -count=1 ./... + go vet ./... + go test -race -count=1 ./... + go build -trimpath -o ../release/nekonest-cloud-relay ./cmd/relay + + - name: Record source provenance + shell: pwsh + env: + RELAY_CORE_TAG: ${{ inputs.relay_core_tag }} + run: | + $cloudCommit = (git -C .. rev-parse HEAD).Trim() + $binaryHash = (Get-FileHash ../release/nekonest-cloud-relay -Algorithm SHA256).Hash.ToLowerInvariant() + [ordered]@{ + cloud_commit = $cloudCommit + relay_core_tag = $env:RELAY_CORE_TAG + binary_sha256 = $binaryHash + built_at = (Get-Date).ToUniversalTime().ToString('o') + } | ConvertTo-Json -Compress | Set-Content -Encoding utf8NoBOM ../release/cloud-relay-provenance.json + + - uses: actions/upload-artifact@v7 + with: + name: nekonest-cloud-relay-${{ github.run_id }} + path: | + release/nekonest-cloud-relay + release/cloud-relay-provenance.json + if-no-files-found: error + retention-days: 7 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a39c94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.npm-cache +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/.vinext/ +/out/ + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +next-env.d.ts +*.tsbuildinfo +/dist/ +/.wrangler/ +/outputs/ +/work/ +/.codegraph/ diff --git a/.openai/hosting.json b/.openai/hosting.json new file mode 100644 index 0000000..01e09ac --- /dev/null +++ b/.openai/hosting.json @@ -0,0 +1,4 @@ +{ + "d1": "DB", + "r2": null +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a8efb7 --- /dev/null +++ b/README.md @@ -0,0 +1,171 @@ +# NekoNest Cloud + +NekoNest Cloud is the private commercial control plane and managed Relay for +the open-source NekoNest data plane. Self-hosted NekoNest remains independent: +it does not contact this repository, read subscriptions, or display Cloud +concepts. + +The managed architecture is deliberately not “one Docker Server per customer”: + +```text +Daemon / Cloud PWA + -> stable Connect origin + -> regional Cloud Relay + -> TenantEngineRegistry + -> one isolated relaycore.Engine per tenant + -> per-tenant SQLite and attachment root + +Cloud control plane + -> account, entitlement, host slots, placement, authorization revisions + -> short-lived signed authorization snapshots for Relay nodes +``` + +One account maps to one tenant/Nest. A host slot is consumed by one non-revoked +host identity, whether it is online or not. A copied device credential remains +the same identity and the managed Relay rejects a second simultaneous daemon; +software cannot prove that a fully copied private key resides on a different +physical machine. + +## Current commercial policy + +- Open-source self-hosting remains free. +- Cloud is a closed, free test while operational and legal gates remain open. +- No quote, order, payment, balance, automatic charge, or token resale path is + enabled. +- The existing entitlement/capacity source is authoritative for host slots. + Registering the N+1 non-revoked identity returns + `device_capacity_exceeded`; revoking a host releases its slot. +- Lowering capacity below the active host count is rejected until the user + explicitly revokes hosts. Cloud never chooses a device to evict. +- Price, billing period, refund, subscription-expiry, and payment-provider + policy remain outside this implementation. + +See [`docs/commercial-contract.md`](./docs/commercial-contract.md) and +[`docs/launch-gates.md`](./docs/launch-gates.md). + +## Implemented control-plane contracts + +- Account, beta entitlement, host-slot, pairing, claim, device credential, + revocation, audit, feedback, retention, and account-exit domain state in D1. +- Atomic host claiming: an identity retry is idempotent and does not consume a + second slot; a different N+1 identity is refused. +- Stable daemon registration. `POST /api/devices/register` returns the existing + device fields plus `connection_state: ready | provisioning` and optional + `retry_after_seconds`; it never returns a tenant Relay URL. +- Regions, Relay nodes, tenant placement, placement generation, + authorization revision, opaque route handles, and single-use handoff tickets. +- Ed25519-signed canonical authorization snapshots with `kid`, issue/expiry, + tenant state, home region, target node, generation, revision, and active + credential digests. +- Relay-node-only internal APIs for live authorization, revision/delta polling, + route resolution, and heartbeat. Node calls use scoped identity; clients + cannot submit a raw tenant ID. +- A 60-second, origin-bound, single-use Dashboard-to-PWA handoff. D1 stores only + the ticket digest. Exchange creates an independent revocable `phone_id`, + `phone_token`, and non-authorizing opaque route handle. Completion first + records a non-authorizing pending principal; only the first request proving + possession of the exact token and route handle activates it and advances the + tenant authorization revision. A lost exchange response therefore cannot + leave an unused active phone identity. +- Login or handoff never grants a phone access to a host. The phone must still + complete NekoNest’s per-device E2E pairing and receive that device’s key + package. +- Stable ingress resolves the current placement for every new connection and + uses authenticated internal HTTPS/WSS forwarding when the home node is + elsewhere; clients never receive a node URL or redirect. +- Fenced backup/restore and migration primitives keep one writable generation. + Application-layer tenant purge closes the Engine, deletes live data, + attachments and all tenant backups, then clears active credentials only + after node acknowledgement. Its completion audit is written only after all + D1 postconditions hold, and the purge job's `completed` marker is the final + fenced mutation. + +The Cloud PWA is built from an exact NekoNest revision. It points at the stable +Connect origin through NekoNest runtime endpoint configuration and refuses any +managed endpoint that is not `sealed`. + +The manual [`cloud-pwa.yml`](./.github/workflows/cloud-pwa.yml) workflow accepts +only a full 40-character NekoNest commit SHA, verifies the checkout, runs the +PWA tests/type-check/build, writes the deploy-time Connect config, records the +source SHA, and emits an immutable build artifact. It does not deploy by itself. + +## Removed legacy design + +The unreleased activation poller, `/api/devices/bootstrap`, tenant +`relay_url` handoff, managed-device manifest, per-tenant Server container, and +single-VPS Node provisioner are intentionally removed. They are not a fallback +or a compatibility mode. A daemon with that old managed configuration must +re-register. + +## Local control-plane development + +Requires Node.js 22.13 or newer: + +```powershell +npm ci +npm run dev +``` + +Production control-plane configuration includes: + +- D1 binding `DB` from [`.openai/hosting.json`](./.openai/hosting.json); +- `NEKONEST_CLOUD_ADMIN_EMAILS` for the temporary closed-test admin boundary; +- `NEKONEST_CLOUD_CREDENTIAL_SECRET` with at least 32 random characters for + purpose-separated HMAC digests; +- Relay snapshot signing keys and active `kid`; +- independent Relay-node mTLS identities and an exact origin allowlist for the + Cloud PWA; +- the stable Connect and PWA origins used by registration and handoff. + +Sites/ChatGPT identity and the development demo viewer are closed-test +scaffolding, not the final public identity or account-recovery decision. + +## Daemon distribution + +The download catalog is fail closed. It accepts only an exact stable NekoNest +tag, exact platform assets, `checksums.txt`, and configured SHA-256 digests; it +never follows `latest`. The compatible daemon must implement protocol 1.3, +stable `server_url`, `ready | provisioning`, and structured service errors. +Artifact hashes do not replace publisher/code signing. + +See [`docs/daemon-distribution.md`](./docs/daemon-distribution.md). + +Relay deployment, internal forwarding, migration and purge configuration are +documented in [`docs/relay-operations.md`](./docs/relay-operations.md). +The manual [`cloud-relay.yml`](./.github/workflows/cloud-relay.yml) workflow +rejects local `replace` directives, verifies the requested `relaycore/vX.Y.Z` +tag against `go.mod`, and runs tests, vet, race checks, and a provenance build +with `GOWORK=off`. + +## Database + +Drizzle schema is in [`db/schema.ts`](./db/schema.ts), with append-only +migrations under [`drizzle/`](./drizzle/). Runtime migration tracking uses +`cloud_schema_migrations`. + +```powershell +npm run db:generate +``` + +Never rewrite a migration already applied to a D1 environment. Because this +repository has not yet served production users, a disposable environment may +be recreated; any non-disposable D1 database must be exported before migration. + +## Verification + +```powershell +npm run type-check +npm run lint +npm test + +cd relay +go test -count=1 ./... +go vet ./... +``` + +The Go Relay has its own tests, race checks, and resource-leak checks. A local +green build is not public-service acceptance. Paid/public launch stays blocked +until exact-build deployment proves stable ingress, cross-tenant isolation, +sealed message and attachment paths, 15-second revocation, five-minute snapshot +expiry, backup/restore, deletion/purge, regional migration and rollback, +monitoring/on-call, retention, identity, domain/filing, and payment policy. diff --git a/app/admin/AdminActions.tsx b/app/admin/AdminActions.tsx new file mode 100644 index 0000000..f464ed9 --- /dev/null +++ b/app/admin/AdminActions.tsx @@ -0,0 +1,384 @@ +"use client"; + +import { useState } from "react"; +import type { + AccountRecord, + AccountDeletionRequestRecord, + BetaAccessRequestRecord, + BetaRecord, + FeedbackRecord, + GrantRecord, + LaunchGateRecord, + RetentionMaintenanceResult, + ServiceIncidentRecord, +} from "@/db/repository"; + +type SubmitState = { loading: boolean; message: string; error: boolean }; +const idle: SubmitState = { loading: false, message: "", error: false }; + +async function postJson = Record>(path: string, payload: Record): Promise { + const response = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...payload, idempotencyKey: crypto.randomUUID() }) }); + const body = (await response.json()) as T & { message?: string }; + if (!response.ok) throw new Error(body.message || "操作失败"); + return body; +} + +export function BetaAction({ beta, blockedP0 }: { beta: BetaRecord | null; blockedP0: number }) { + const [enabled, setEnabled] = useState(beta?.state === "active"); + const [capacity, setCapacity] = useState(beta?.capacity_slots?.toString() ?? ""); + const [reason, setReason] = useState(""); + const [state, setState] = useState(idle); + async function submit(event: React.FormEvent) { + event.preventDefault(); setState({ loading: true, message: "", error: false }); + try { await postJson("/api/admin/beta", { enabled, capacitySlots: capacity ? Number(capacity) : null, reason }); setState({ loading: false, message: "新的公测政策版本已写入,页面即将刷新。", error: false }); window.setTimeout(() => window.location.reload(), 700); } + catch (error) { setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true }); } + } + return
{blockedP0 > 0 &&

仍有 {blockedP0} 项 P0 未通过:可以预设免费政策,但新的公开配对继续冻结;只有明确签发的闭测邀请可继续使用。

}