Commit Graph
12 Commits
Author SHA1 Message Date
chenchenandClaude Opus 4.7 5081289b65 fix(devices): hide revoked rows from user-facing Devices list
User feedback: "都已经已撤销了为什么还有记录" — once a user clicks
revoke they expect the row gone from the list, not lingering with
a "已撤销" badge. The old behaviour treated the page as a security
audit log, which conflicts with its primary use as an active-device
management surface.

GetUserDeviceBoundTokens now filters `revoked_at = 0`. The row stays
in DB (soft-delete) so:
  - audit trail (RevokedAt / RevokedReason / DeviceLastSeenIp /
    DeviceFingerprint) remains inspectable by admins
  - re-pair from the same physical device still self-heals the row
    via the reactivate branch in PairDevice — covered by existing
    TestPairDevice_RepairAfterRevokeReactivates

If a user wants security audit history in the UI, that should be a
separate "Security activity" page; not the device-management list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:24:20 +08:00
chenchenandClaude Opus 4.7 b48be15d8b fix(http): set TrustedPlatform=Cloudflare so c.ClientIP reads CF-Connecting-IP
Previous SetTrustedProxies commit (407dbb7) was necessary but
insufficient. In production Manager sits behind Cloudflare in
proxy mode, which:
  - strips the inbound X-Forwarded-For header
  - sets CF-Connecting-IP with the real client IP

Gin's default ClientIP() only knows about X-Forwarded-For + X-Real-IP
— it does NOT recognize CF-Connecting-IP. So every request showed the
docker bridge peer (10.2.3.4) in audit fields and rate-limit buckets
even after we added private ranges to TrustedProxies.

Setting TrustedPlatform = gin.PlatformCloudflare instructs Gin to
read CF-Connecting-IP as ground truth, bypassing the XFF parser.
When the header is absent (health checks, direct non-CF probes)
Gin falls back through TrustedProxies → XFF → RemoteAddr as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:56:11 +08:00
chenchenandClaude Opus 4.7 407dbb7200 fix(http): trust private-range proxies so c.ClientIP returns real IP
Manager was created via gin.New() without calling SetTrustedProxies,
which in Gin v1.7+ defaults to trusting NOTHING — c.ClientIP() returned
the docker bridge peer (e.g. 10.2.3.4) instead of the real client IP
populated in X-Forwarded-For by the front reverse proxy.

Symptoms observed in production:
  - Devices page showed every user's "Last IP" as 10.2.3.4 / 10.2.3.5
  - tokens.device_last_seen_ip audit field useless for security review
  - Token IP allowlists effectively bypassed (always saw docker IP)
  - Rate-limit buckets keyed on docker IP — all users share a bucket

Fix: SetTrustedProxies with the standard RFC1918 + loopback ranges.
Covers every realistic Manager topology (docker compose, k8s ClusterIP,
reverse proxy on same VM). Cloudflare-direct topologies still need the
CF published ranges added; document that inline rather than auto-fetch
since we currently always front with Caddy/nginx.

UI cosmetic: When device_name is empty (pre-0.3.3 desktop clients
didn't always send it), Devices page now synthesises a label like
"Windows · 4f3a" from platform + last 4 chars of device_id instead
of the generic "Unnamed device", so users can tell their devices
apart at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:45:53 +08:00
chenchenandClaude Opus 4.7 b615f3dc67 fix(devices): V2 device-binding hardening + Web Devices UI
Server-side bug fixes (zero client-impact):
- fix(devices): re-pair after revoke now reactivates the row instead of
  returning a stale "reused:true" response. Before this, a user who
  revoked a device in Web UI then re-launched the desktop app got
  HTTP 200 from /pair but every subsequent V2 request 401'd with
  ErrDeviceRevoked, leaving them locked out.
- feat(v2): V2 auth failures now carry X-Heicode-Server-Time and
  X-Heicode-Auth-Error response headers. Lets the desktop client
  distinguish clock drift (timestamp_drift) from revoke/signature
  failures and show actionable messages instead of "Token invalid".
- fix(devices): RenameUserDevice rejects whitespace-only names (400)
  and truncates by rune count instead of bytes, so multi-byte UTF-8
  names (Chinese / Japanese) don't get mangled at the 64-byte boundary.
- feat(devices): RevokeUserDevice writes a SysLog audit line with
  user_id / token_id / device_id / device_name / operator IP / reason.
  Symmetric with the existing "reactivated revoked device" log so
  admins can trace both transitions when investigating lockouts.
- fix(devices): GetUserDeviceBoundTokens sort uses
  CASE WHEN device_last_used_at = 0 THEN device_bound_at ELSE
  device_last_used_at END DESC so a freshly-paired device doesn't
  sink below older but actively-used machines in the Devices list.
  Portable across SQLite / MySQL / PostgreSQL.

Web UI (web/default):
- New /devices route + features/devices/ page with table, revoke
  AlertDialog, rename Dialog, greyed-out revoked rows, empty state.
- Sidebar "Personal" group now shows "Devices" between Models and
  Account security (Smartphone icon).
- i18n strings added to zh.json + en.json.

Tests:
- 8 new tests covering re-pair reactivation, rename validation
  edge cases, sort order, audit log shape, V2 error code mapping,
  and diagnostic header emission. Full controller / middleware /
  model suite remains green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:46:07 +08:00
chenchenandClaude Opus 4.7 683ecc7470 feat(heicode): GET /api/heicode/self for desktop balance + usage
The cc-haha desktop client used to read its balance from
/v1/dashboard/billing/{subscription,usage}. Those endpoints honor the
token row's UnlimitedQuota flag — and device-bound tokens have that
flag set true because they are an auth mechanism, not a billing
boundary. Result: the balance pill always showed 100_000_000 USD
regardless of the user's real balance.

The right source is the user row (User.Quota / UsedQuota /
RequestCount), which is what /api/user/self surfaces to the web
dashboard. But that endpoint is UserAuth-only (session cookie / JWT),
which the desktop client doesn't carry — it holds a sk- bearer or
signs requests with its V2 device key.

This commit adds a slim sibling endpoint /api/heicode/self mounted on
TokenAuth so either sk- or V2 signature authenticates. Returns only
the fields the desktop balance pill + usage panel consume (quota,
used_quota, request_count, plus username/group/role for the title
bar) — no PII beyond what relay calls already expose. Quota numbers
go through the same QuotaPerUnit / display-type normalization that
billing.go uses, so the desktop pill and web dashboard show the same
number.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:31:21 +08:00
chenchenandClaude Opus 4.7 e2cac00c14 fix(devices): pair is idempotent, cap only counts new devices
Old behavior: any (user_id, device_id) duplicate returned 409 and the
per-user device cap was checked BEFORE the dup-check. Combined effect:
a client that already paired once but lost the Manager row (or just
wants to re-confirm on every startup) hit 409 or 403 forever, with no
way to recover except an admin DELETE.

New behavior:
- Same (user_id, device_id, pubkey) tuple → 200 with reused:true.
  Lets bootstrap call pair on every login as an idempotent liveness
  probe.
- Same (user_id, device_id) but different pubkey → 409 with explicit
  "already paired with different key" message. Client treats this as
  a signal to clear local identity and regenerate.
- Cap check moved AFTER the dup check so re-pair of an existing
  device is never blocked by "device limit reached".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:13:50 +08:00
chenchen 16cec0ee9d fix(devices): pair tokens are unlimited-quota (inherit from User row)
After 67225fd fixed the V2 chat 403 caused by missing SetupContextForToken,
the next probe call surfaced a new 403:
  "token quota is not enough, token remain quota: \$0.000000,
   need quota: \$0.001590"

Root cause: PairDevice initialised the new tokens row with
UnlimitedQuota=false and didn't set RemainQuota, so it defaulted to 0.
Every subsequent V2 chat then failed at pre-consume since the token had
no spendable budget — even though the user's actual User.Quota was
positive.

Device tokens aren't a billing boundary in our model; they're the
Ed25519 binding for a single client install. Quota belongs on the User
row. Flip UnlimitedQuota=true so the relay path consumes from
User.Quota directly, matching exactly what the legacy sk- bearer was
already doing (legacy tokens in this deployment are unlimited too).

Verified end-to-end via /tmp/v2_probe2.js after deploy: POST
/v1/messages with full V2 envelope returns HTTP 200 with the model's
reply.
2026-05-21 03:55:17 +08:00
chenchen 67225fd67e fix(v2): TokenAuth V2 path must call SetupContextForToken
V2 chat returned HTTP 403 with body
  {"error":{"type":"new_api_error","message":"record not found ..."}}
even after Manager body_decrypt and Ed25519 verify both passed and the
device row was found. Root cause: the V2 dispatch in TokenAuth set
`id` + `token_id` directly via VerifyV2DeviceSignedRequest, called
applyTokenPolicyAndContext for IP/user/group checks, then jumped to
c.Next() — skipping SetupContextForToken entirely.

SetupContextForToken populates eight more keys the downstream relay
and billing pipeline expect:
  token_key, token_name, token_unlimited_quota, token_quota,
  token_model_limit_enabled, token_model_limit,
  ContextKeyTokenGroup, ContextKeyTokenCrossGroupRetry

Without them, channel distribute / pre-consume / log_consume
silently misroute and a generic "record not found" leaks out as 403.
The legacy bearer path didn't have this bug because it always
finished with SetupContextForToken before c.Next().

Verified with /tmp/v2_probe2.js (after this deploys): POST
/v1/messages with full V2 envelope returns HTTP 200 with the model's
reply body.
2026-05-21 03:50:04 +08:00
chenchen 52549bb2af fix(devices): /api/devices/pair accepts sk- bearer (TokenOrUserAuth)
Real test in C:\temp\v2_probe.js shows POST /api/devices/pair returning
HTTP 200 with body {"success":false, "message":"Unauthorized, invalid
access token"} when called with a Bearer sk- — the same sk- the OAuth
callback hands the client. Root cause: the route group used UserAuth(),
which only accepts a session cookie or a user JWT in Authorization, not
a relay-tier sk- bearer.

The OAuth-redirect flow (Heicode default) never produces a JWT — it
just hands cc-haha a sk-. So in production the pair call after
"一键登录" always 401'd, device-binding never activated, and V2
encryptedFetch silently fell back to legacy bearer for every request.

Fix: split the /devices route into two groups.
  - /devices/* (list, rename, revoke): still UserAuth(). A sk- must
    NOT be allowed to enumerate or revoke another device — that
    would let an attacker with a stolen sk- delete the legitimate
    owner's device binding.
  - /devices/pair: TokenOrUserAuth(). Pair is the bootstrap step, by
    definition no device key exists yet, so sk- IS the only credential
    available on the OAuth-redirect flow.

TokenOrUserAuth calls c.Set("id", token.UserId) via its TokenAuth
fallback, so the PairDevice controller's c.GetInt("id") keeps working.

Verified by re-running v2_probe.js after deploy: pair returns
HTTP 200 success:true.
2026-05-21 03:43:44 +08:00
chenchen 22ee18d2da feat(manager): V2 device-bound signed + body-encrypted protocol
Eliminate sk- bearer from the client wire entirely. V2 requests
authenticate via Ed25519 device signature (over a canonical that
binds method/path/timestamp/nonce/fingerprint/eph-pubkey/plaintext-
body-hash) and encrypt the request body with X25519 ECDH +
ChaCha20-Poly1305-AEAD. Server-issued sk- tokens still exist for
legacy callers during a 30-day deadline window; after the deadline
bare-bearer sk- on /v1/* is rejected.

What's new server-side:

- model/server_key.go + service/server_keys.go: long-lived X25519
  keypair persisted in DB. Private half is AES-256-GCM-sealed with a
  key derived from CRYPTO_SECRET so a SQL dump alone doesn't leak it.
  Generated on first launch by main.go::EnsureServerECDHKey.

- common/crypto.go: SealWithCryptoSecret / UnsealWithCryptoSecret
  helpers (AES-GCM); SafeWipe defense-in-depth zero-out.

- controller/server_pubkey.go + GET /api/server-pubkey: public
  endpoint clients fetch at startup to obtain the ECDH pubkey.

- middleware/body_decrypt.go: ChaCha20-Poly1305 decrypt of V2 bodies.
  AD binds device_id/timestamp/nonce/method/path so tampering any
  fails AEAD verify. Replaces c.Request.Body with plaintext for
  downstream relay handlers to consume unchanged.

- middleware/device_signature.go: new VerifyV2DeviceSignedRequest()
  looks up token by device_id (not bearer) and verifies an extended
  canonical that includes the ephemeral pubkey + plaintext body hash.

- middleware/auth.go::TokenAuth: dispatch on Content-Encoding header.
  V2 path skips ValidateUserToken entirely. Legacy path adds a 30-day
  /v1/* deadline knob.

- model/token.go::FindTokenByDeviceId: V2 lookup helper.

- controller/device.go::PairDevice: stops returning the sk in
  responses. Client identifies itself by device_id + signature from
  now on, no bearer needed.

- setting/operation_setting/device_binding_setting.go: new
  LegacySkV1DeadlineMs knob (0 = disabled until operator sets it).

Backward compatibility: V1 device-signed tokens (those issued by
the earlier PairDevice that DID return a sk-) keep working through
the legacy bearer path; the existing V1 signature middleware still
runs for them. The 30-day deadline is opt-in until ops sets it.

Tests: V1 regression suite passes (middleware + common).
V2-specific tests come in a follow-up commit alongside the client
encryptedFetch wiring; deferring lets us land the server-side
plumbing first without coupling.
2026-05-20 16:43:36 +08:00
chenchen 9e852d788f 删除网站 2026-05-20 14:39:58 +08:00
chenchen 2a1d8d8191 chore: initial import — heicode manager + website 2026-05-20 14:07:30 +08:00