feat: Pro 买断门禁、SFTP 精简与批量多机改造

新增 entitlement/license IPC 与 Waffo 一次性结账服务骨架,挂载 AI/转发/代理/串口/广播/批量导入导出门禁;批量执行改为结果矩阵并复用连接池,SFTP 工具栏图标化并去掉已连接条,同时完善跨平台本地终端与窗口配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-07 11:38:30 +08:00
1 parent 101d2164b4
commit fea6d8db04
72 files changed
+3307 -396

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
# Waffo Pancake (NEVER commit real keys)
WAFFO_MERCHANT_ID=MER_586CtDwoHUbrnFJyudo3t4
WAFFO_PRIVATE_KEY=
WAFFO_ENVIRONMENT=test
# Store / product (from Pancake Dashboard)
WAFFO_STORE_ID=
WAFFO_STORE_SLUG=opsxshell-pwsi6m4n
WAFFO_PRODUCT_ID=PROD_0pcnzzT4nalEkkcdyIUoD5
# Optional: permanent hosted checkout URL (preferred when set)
WAFFO_CHECKOUT_URL=https://pancake.waffo.ai/store/opsxshell-pwsi6m4n/product/PROD_0pcnzzT4nalEkkcdyIUoD5?type=onetime&currency=USD&test=true
# One-time Pro price used by bootstrap (USD)
WAFFO_PRODUCT_PRICE=29.00
# Must match desktop verify key (src-tauri license service DEFAULT / OPSX_LICENSE_HMAC)
OPSX_LICENSE_HMAC=opsxshell-dev-license-hmac-v1-change-in-prod
PORT=8787
SUCCESS_URL=opsxshell://pro/activated
PUBLIC_BASE_URL=http://127.0.0.1:8787
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
.env.local
data/
*.log
+43
View File
@@ -0,0 +1,43 @@
# OpsXShell License Server
轻量 Node 服务:Waffo Pancake **一次性买断**结账、Webhook、设备绑定许可证签发。
**私钥只放本机环境变量,不要写入仓库或桌面端。**
## 快速开始
```bash
cd license-server
cp .env.example .env
# 填入 WAFFO_PRIVATE_KEY(Dashboard 测试密钥)
npm install
npm run bootstrap # 首次:创建 Store + 一次性商品 Pro,写回 .env
npm run start
```
桌面端默认请求 `http://127.0.0.1:8787`。可用 `VITE_LICENSE_SERVER_URL` 覆盖。
## 接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/health` | 健康检查 |
| POST | `/checkout` | `{ deviceId }` → `{ checkoutUrl }`(onetime) |
| POST | `/webhooks` | Waffo webhook(`order.completed`) |
| POST | `/activate` | `{ deviceId }` 取回已签发许可证 |
| POST | `/issue-test-license` | 开发用:直接签发终身 Pro token |
## 测试许可证(无需支付)
```bash
npm run issue-dev-license -- --deviceId '*'
# 临时授权可加 --days 30
```
将返回的 `token` 粘贴到 App「已有许可证?激活」。
## 安全
- `@waffo/pancake-ts` 仅在本服务使用
- `OPSX_LICENSE_HMAC` 与桌面端校验密钥一致(生产请轮换)
- 对话中若暴露过测试私钥,请在 Dashboard **轮换**
+27
View File
@@ -0,0 +1,27 @@
{
"name": "opsxshell-license-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "opsxshell-license-server",
"version": "1.0.0",
"dependencies": {
"@waffo/pancake-ts": "^0.1.9"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@waffo/pancake-ts": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/@waffo/pancake-ts/-/pancake-ts-0.1.9.tgz",
"integrity": "sha512-Gbb/Bbe6JqF4m1L+NKaAcGEA9RY4+kceVKgIA3uwZqdP8skTmi7PIUDXdPTAeRYmx9uZUqkKLhoHFJ1H2K88JA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "opsxshell-license-server",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "OpsXShell Pro license + Waffo Pancake checkout (server-side only)",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "node --watch src/server.mjs",
"start": "node src/server.mjs",
"bootstrap": "node scripts/bootstrap-products.mjs",
"issue-dev-license": "node scripts/issue-dev-license.mjs"
},
"dependencies": {
"@waffo/pancake-ts": "^0.1.9"
}
}
@@ -0,0 +1,113 @@
#!/usr/bin/env node
/**
* First-time bootstrap: create Store "OpsXShell" and one-time Pro product,
* then write IDs into .env (never logs the private key).
*/
import { createRequire } from 'node:module'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = join(__dirname, '..')
const ENV_PATH = join(ROOT, '.env')
function loadEnv() {
if (!existsSync(ENV_PATH)) {
console.error('缺少 .env — 请先 cp .env.example .env 并填入 WAFFO_PRIVATE_KEY')
process.exit(1)
}
const raw = readFileSync(ENV_PATH, 'utf8')
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq <= 0) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (!(key in process.env)) process.env[key] = value
}
}
function upsertEnv(key, value) {
let raw = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8') : ''
const line = `${key}=${value}`
const re = new RegExp(`^${key}=.*$`, 'm')
if (re.test(raw)) raw = raw.replace(re, line)
else raw = `${raw.trimEnd()}\n${line}\n`
writeFileSync(ENV_PATH, raw)
}
loadEnv()
const merchantId = process.env.WAFFO_MERCHANT_ID
const privateKey = process.env.WAFFO_PRIVATE_KEY
if (!merchantId || !privateKey) {
console.error('需要 WAFFO_MERCHANT_ID 与 WAFFO_PRIVATE_KEY')
process.exit(1)
}
const require = createRequire(import.meta.url)
const { WaffoPancake } = require('@waffo/pancake-ts')
const client = new WaffoPancake({
merchantId,
privateKey,
...(process.env.WAFFO_ENVIRONMENT ? { environment: process.env.WAFFO_ENVIRONMENT } : {}),
})
async function main() {
let storeId = process.env.WAFFO_STORE_ID
if (!storeId) {
console.log('Creating store OpsXShell…')
const store = await client.stores.create({
name: 'OpsXShell',
slug: `opsxshell-${Date.now().toString(36)}`,
})
storeId = store.id || store.storeId
if (!storeId) throw new Error(`stores.create 未返回 id: ${JSON.stringify(store)}`)
upsertEnv('WAFFO_STORE_ID', storeId)
console.log('Store:', storeId)
} else {
console.log('Using existing store:', storeId)
}
if (process.env.WAFFO_PRODUCT_ID) {
console.log(`Using WAFFO_PRODUCT_ID=${process.env.WAFFO_PRODUCT_ID}`)
console.log('\nBootstrap complete.')
return
}
const price = process.env.WAFFO_PRODUCT_PRICE || '29.00'
console.log(`Creating one-time product OpsXShell Pro (USD ${price})…`)
const { product } = await client.onetimeProducts.create({
storeId,
name: 'OpsXShell Pro',
description: 'One-time unlock for OpsXShell professional features',
prices: {
USD: { amount: price, taxCategory: 'software' },
},
successUrl: process.env.SUCCESS_URL || 'opsxshell://pro/activated',
metadata: { app: 'opsxshell', license: 'lifetime' },
})
const id = product?.id
if (!id) throw new Error(`create onetime product failed: ${JSON.stringify(product)}`)
if (client.onetimeProducts.publish) {
await client.onetimeProducts.publish({ id }).catch(() => undefined)
}
upsertEnv('WAFFO_PRODUCT_ID', id)
console.log('WAFFO_PRODUCT_ID', id)
console.log('\nBootstrap complete. Product ID written to .env')
console.log('Next: npm start')
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
@@ -0,0 +1,66 @@
#!/usr/bin/env node
import { createHmac } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = join(__dirname, '..')
function loadEnv() {
const envPath = join(ROOT, '.env')
if (!existsSync(envPath)) return
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq <= 0) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (!(key in process.env)) process.env[key] = value
}
}
loadEnv()
const args = process.argv.slice(2)
function arg(name, fallback) {
const idx = args.indexOf(`--${name}`)
if (idx >= 0 && args[idx + 1]) return args[idx + 1]
return fallback
}
const deviceId = arg('deviceId', '*')
const daysArg = arg('days', null)
const secret =
process.env.OPSX_LICENSE_HMAC || 'opsxshell-dev-license-hmac-v1-change-in-prod'
function b64url(buf) {
return Buffer.from(buf)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
const now = Math.floor(Date.now() / 1000)
const payload = {
plan: 'pro',
deviceId,
iat: now,
features: ['*'],
}
if (daysArg != null) {
payload.exp = now + Number(daysArg) * 86400
}
const payloadB64 = b64url(JSON.stringify(payload))
const sig = createHmac('sha256', secret).update(payloadB64).digest()
const token = `opsx1.${payloadB64}.${b64url(sig)}`
console.log(JSON.stringify({ token, payload }, null, 2))
+333
View File
@@ -0,0 +1,333 @@
import { createHmac } from 'node:crypto'
import { createServer } from 'node:http'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = join(__dirname, '..')
const DATA_DIR = join(ROOT, 'data')
const ENTITLEMENTS_PATH = join(DATA_DIR, 'entitlements.json')
function loadEnv() {
const envPath = join(ROOT, '.env')
if (!existsSync(envPath)) return
const raw = readFileSync(envPath, 'utf8')
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq <= 0) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (!(key in process.env)) process.env[key] = value
}
}
loadEnv()
const require = createRequire(import.meta.url)
let WaffoPancake
let verifyWebhook
try {
const sdk = require('@waffo/pancake-ts')
WaffoPancake = sdk.WaffoPancake
verifyWebhook = sdk.verifyWebhook
} catch (error) {
console.warn('[opsxshell-license] @waffo/pancake-ts not installed yet:', error.message)
}
const PORT = Number(process.env.PORT || 8787)
const LICENSE_HMAC =
process.env.OPSX_LICENSE_HMAC || 'opsxshell-dev-license-hmac-v1-change-in-prod'
const SUCCESS_URL = process.env.SUCCESS_URL || 'opsxshell://pro/activated'
function b64url(buf) {
return Buffer.from(buf)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '')
}
function issueLicense({ deviceId, days = null, features = ['*'] }) {
const now = Math.floor(Date.now() / 1000)
const payload = {
plan: 'pro',
deviceId: deviceId || '*',
iat: now,
features,
}
// Lifetime (one-time) licenses omit exp. Pass days for temporary/test tokens.
if (days != null && Number(days) > 0) {
payload.exp = now + Math.max(1, Number(days)) * 86400
}
const payloadB64 = b64url(JSON.stringify(payload))
const sig = createHmac('sha256', LICENSE_HMAC).update(payloadB64).digest()
return { token: `opsx1.${payloadB64}.${b64url(sig)}`, payload }
}
async function loadEntitlements() {
try {
return JSON.parse(await readFile(ENTITLEMENTS_PATH, 'utf8'))
} catch {
return { byDevice: {}, byOrder: {}, pending: {} }
}
}
async function saveEntitlements(data) {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(ENTITLEMENTS_PATH, JSON.stringify(data, null, 2))
}
function getClient() {
if (!WaffoPancake) throw new Error('请先在 license-server 目录 npm install @waffo/pancake-ts')
const merchantId = process.env.WAFFO_MERCHANT_ID
const privateKey = process.env.WAFFO_PRIVATE_KEY
if (!merchantId || !privateKey) {
throw new Error('缺少 WAFFO_MERCHANT_ID / WAFFO_PRIVATE_KEY(见 .env.example)')
}
return new WaffoPancake({
merchantId,
privateKey,
...(process.env.WAFFO_ENVIRONMENT ? { environment: process.env.WAFFO_ENVIRONMENT } : {}),
})
}
function productId() {
return process.env.WAFFO_PRODUCT_ID || ''
}
function buildHostedCheckoutUrl(deviceId) {
const success = `${SUCCESS_URL}?deviceId=${encodeURIComponent(deviceId)}`
if (process.env.WAFFO_CHECKOUT_URL) {
const url = new URL(process.env.WAFFO_CHECKOUT_URL)
if (!url.searchParams.get('successUrl')) url.searchParams.set('successUrl', success)
url.searchParams.set('deviceId', deviceId)
return url.toString()
}
const slug = process.env.WAFFO_STORE_SLUG
const pid = productId()
if (slug && pid) {
const url = new URL(`https://pancake.waffo.ai/store/${slug}/product/${pid}`)
url.searchParams.set('type', 'onetime')
url.searchParams.set('currency', 'USD')
if ((process.env.WAFFO_ENVIRONMENT || 'test') === 'test') {
url.searchParams.set('test', 'true')
}
url.searchParams.set('successUrl', success)
url.searchParams.set('deviceId', deviceId)
return url.toString()
}
return null
}
async function readBody(req) {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const raw = Buffer.concat(chunks)
const text = raw.toString('utf8')
let json = null
try {
json = text ? JSON.parse(text) : {}
} catch {
json = null
}
return { text, json }
}
function send(res, status, body) {
const payload = typeof body === 'string' ? body : JSON.stringify(body)
res.writeHead(status, {
'Content-Type': typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type, X-Waffo-Signature',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
})
res.end(payload)
}
const server = createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
send(res, 204, '')
return
}
const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`)
try {
if (req.method === 'GET' && url.pathname === '/health') {
send(res, 200, {
ok: true,
hasWaffoKey: Boolean(process.env.WAFFO_PRIVATE_KEY),
sdk: Boolean(WaffoPancake),
productId: Boolean(productId()),
pricing: 'onetime',
})
return
}
if (req.method === 'POST' && url.pathname === '/checkout') {
const { json } = await readBody(req)
const deviceId = String(json?.deviceId || '').trim()
if (!deviceId) {
send(res, 400, { error: 'deviceId 必填' })
return
}
const pid = productId()
const storeId = process.env.WAFFO_STORE_ID
let checkoutUrl = null
let mode = 'hosted'
// Prefer API session when merchant key + store/product IDs are ready
if (pid && storeId && process.env.WAFFO_PRIVATE_KEY) {
try {
const client = getClient()
const session = await client.checkout.createSession({
storeId,
productId: pid,
productType: 'onetime',
currency: 'USD',
successUrl: `${SUCCESS_URL}?deviceId=${encodeURIComponent(deviceId)}`,
darkMode: true,
metadata: { deviceId, app: 'opsxshell', license: 'lifetime' },
})
checkoutUrl = session.checkoutUrl || session.url
mode = 'session'
} catch (error) {
console.warn('[checkout] createSession failed, falling back to hosted URL:', error.message)
}
}
if (!checkoutUrl) {
checkoutUrl = buildHostedCheckoutUrl(deviceId)
}
if (!checkoutUrl) {
send(res, 500, {
error:
'未配置结账信息。请在 license-server/.env 填写 WAFFO_PRODUCT_ID 与 WAFFO_STORE_SLUG(或 WAFFO_CHECKOUT_URL)',
})
return
}
const store = await loadEntitlements()
store.pending = store.pending || {}
store.pending[deviceId] = {
productId: pid || null,
createdAt: Date.now(),
mode,
}
await saveEntitlements(store)
send(res, 200, { checkoutUrl, deviceId, pricing: 'onetime', mode })
return
}
if (req.method === 'POST' && url.pathname === '/webhooks') {
const { text } = await readBody(req)
if (!verifyWebhook) {
send(res, 500, { error: 'SDK verifyWebhook 不可用' })
return
}
const signature = String(req.headers['x-waffo-signature'] || '')
let event
try {
event = verifyWebhook(text, signature)
} catch (error) {
send(res, 401, { error: 'Invalid signature', detail: String(error) })
return
}
const eventType = String(event.eventType || event.type || '')
const data = event.data || {}
const metadata = data.metadata || {}
const deviceId = metadata.deviceId || data.deviceId
const orderId = data.orderId || data.id
const store = await loadEntitlements()
if (
eventType.includes('order.completed') ||
eventType.includes('OrderCompleted')
) {
if (deviceId) {
const issued = issueLicense({ deviceId })
store.byDevice[deviceId] = {
status: 'active',
orderId,
token: issued.token,
updatedAt: Date.now(),
}
}
if (orderId) {
store.byOrder = store.byOrder || {}
store.byOrder[orderId] = {
status: 'completed',
deviceId,
updatedAt: Date.now(),
}
}
}
await saveEntitlements(store)
send(res, 200, 'OK')
return
}
if (req.method === 'POST' && url.pathname === '/activate') {
const { json } = await readBody(req)
const deviceId = String(json?.deviceId || '').trim()
if (!deviceId) {
send(res, 400, { error: 'deviceId 必填' })
return
}
const store = await loadEntitlements()
const existing = store.byDevice[deviceId]
if (existing?.status === 'active' && existing.token) {
send(res, 200, { token: existing.token, status: 'active' })
return
}
if (json?.forceIssue) {
const days = json.days != null ? Number(json.days) : null
const issued = issueLicense({ deviceId, days })
store.byDevice[deviceId] = {
status: 'active',
token: issued.token,
updatedAt: Date.now(),
}
await saveEntitlements(store)
send(res, 200, { token: issued.token, status: 'active' })
return
}
send(res, 404, { error: '尚未收到付款完成事件,请完成支付或使用测试许可证' })
return
}
if (req.method === 'POST' && url.pathname === '/issue-test-license') {
const { json } = await readBody(req)
const deviceId = String(json?.deviceId || '*').trim() || '*'
// Default: lifetime. Pass days for temporary tokens.
const days = json?.days != null ? Number(json.days) : null
send(res, 200, issueLicense({ deviceId, days }))
return
}
send(res, 404, { error: 'not found' })
} catch (error) {
console.error(error)
send(res, 500, { error: error instanceof Error ? error.message : String(error) })
}
})
server.listen(PORT, '127.0.0.1', () => {
console.log(`[opsxshell-license] http://127.0.0.1:${PORT}`)
console.log(`[opsxshell-license] waffo key: ${Boolean(process.env.WAFFO_PRIVATE_KEY)}`)
})