Skip to content

feat(sub): add read-only HWID device-slot status endpoint - #6380

Open
Namso9 wants to merge 1 commit into
MHSanaei:mainfrom
Namso9:feat/sub-hwid-status
Open

feat(sub): add read-only HWID device-slot status endpoint#6380
Namso9 wants to merge 1 commit into
MHSanaei:mainfrom
Namso9:feat/sub-hwid-status

Conversation

@Namso9

@Namso9 Namso9 commented Aug 31, 2026

Copy link
Copy Markdown

Closes #6357

What

Adds one additive, public, read-only endpoint on the subscription router, as requested in #6357:

GET /{subPath}/{subId}/hwid-status
→ {"active":true,"limit":2,"registered":1,"remaining":1,"full":false}

A subscriber (or a customer-facing panel / reseller bot built on the subscription API) can now show "Devices 1/2 — 1 slot remaining" instead of a silently failing connection.

Behaviour

  • SELECT-only. It never registers an HWID, never touches last_seen, and never calls EnforceHwidForSubID, so asking about a slot cannot spend one. A test asserts client_hwids stays empty after a probe that carries X-HWID, and that the next real fetch with that same HWID still succeeds.
  • Aggregate numbers only — no HWID value or hash, no email, no device model, no IP, no User-Agent, and none of the X-Hwid-* gate headers. The test decodes into a map and asserts the exact field set, so an added field fails.
  • No new auth. The subscription id is already the bearer secret for /{subPath}/{subId}.
  • Unknown and disabled subscriptions are indistinguishable: both take the same single-query path to a bare 404, and a test asserts identical status, headers and body.
  • No HWID limit{"active":false,"limit":0,"registered":0,"remaining":0,"full":false}.
  • No schema change, no migration.
  • Existing contracts are untouched: /{subPath}/{subId}, ?format=info, and the JSON/Clash routes are not modified at all.

Notes for reviewers

  • HwidSlotStatusForSubID scopes the limit through the existing effectiveHwidLimitForSubID helper (enabled clients, MAX(limit_hwid)), so the reported limit is always the enforced limit on a shared sub_id. That is also why existence is "≥1 enabled client with this sub_id" — the same notion the gate uses — rather than the body route's "enabled inbound joined to the sub_id".
  • remaining clamps at zero: deleting or disabling the highest-limit client of a shared sub_id lowers the effective MAX without trimming rows, so registered > limit is reachable. Covered by a test.
  • The three counters come from separate SELECTs, so a limit edit landing mid-request can return a slightly stale tuple. Deliberate: this is an advisory read that self-corrects on the next poll, and a transaction would add write-lock contention on the subscription hot path.
  • HEAD is registered alongside GET, matching the sibling subscription routes.
  • Docs: the endpoint is registered in frontend/src/pages/api-docs/endpoints.ts, make gen was run, and frontend/public/openapi.jsondocs/public/openapi.json + cd docs && pnpm gen:api were regenerated. The response is documented in prose because build-openapi.mjs wraps every operation in the {success,msg,obj} panel envelope, which does not apply to subscription-server routes.

How to test

go test ./internal/sub/ -run TestSubscriptionHwid
go test ./internal/web/service/ -run TestClientHwid

Manually, with a client that has limitHwid = 1:

curl -fsS 'http://127.0.0.1:10882/sub/SUB_ID/hwid-status'
# {"active":true,"limit":1,"registered":0,"remaining":1,"full":false}
curl -fsS -H 'X-HWID: device-one' 'http://127.0.0.1:10882/sub/SUB_ID' >/dev/null
curl -fsS 'http://127.0.0.1:10882/sub/SUB_ID/hwid-status'
# {"active":true,"limit":1,"registered":1,"remaining":0,"full":true}

Checks run locally

go build ./..., go vet, go test -shuffle=on -count=1 ./..., go test -race -shuffle=on ./internal/sub/ ./internal/web/service/, frontend typecheck / lint / format:check, and make gen reproducing the committed generated files. (internal/mtproto's two process-spawn timing tests flaked once on a loaded laptop and pass on a re-run; they are untouched by this change.)

Closes MHSanaei#6357

A client with an HWID limit had no way to tell a subscriber how many device
slots were left: /{subPath}/{subId} only exposes the gate as a boolean through
X-Hwid-* headers on a 404, and ?format=info carries no limitHwid or registered
count. Every "why can't I connect on my new phone" case therefore had to be
answered by the operator by hand.

GET /{subPath}/{subId}/hwid-status now returns the aggregate counters:

  {"active":true,"limit":2,"registered":1,"remaining":1,"full":false}

- SELECT-only. It never registers an hwid, never touches last_seen and never
  calls the enforcement path, so asking about a slot cannot spend one.
- Counters only: no hwid value or hash, no email, no device metadata, no IP,
  no User-Agent, and none of the X-Hwid-* gate headers.
- The subscription id is already the bearer secret for /{subPath}/{subId}, so
  no admin token and no new auth mechanism.
- Unknown and disabled subscriptions both answer a bare 404, with identical
  status, headers and body, so the route cannot be used to probe which
  subscription ids exist.
- No HWID limit configured returns {"active":false,"limit":0,...}.
- No schema change and no migration.

Scoped to enabled clients exactly like effectiveHwidLimitForSubID, so the
reported limit is always the limit the gate enforces on a shared sub_id, and
remaining clamps at zero when the effective limit drops below the number of
registered devices. A separate route leaves /{subPath}/{subId}, ?format=info
and the JSON/Clash routes byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Code review

No blocking issues0 🔴 / 0 🟡 / 0 🟣

The change is additive and self-consistent: a SELECT-only handler, a read-only service method, two route registrations, tests, and the full regenerated doc chain. Nothing this repo treats as Important came back dirty.

One thing to settle before merge that is not a code defect: no CI has run on this head. All four workflows on 01cf1f3 are action_required (fork PR awaiting maintainer approval): CI, Docs CI, CodeQL Advanced, Release 3X-UI. So gen-check, lint, test-go, race and vulncheck have never executed against this commit — everything below rests on reading the code, not on a green tick. make gen reproducibility of the committed openapi.json/MDX is exactly what gen-check exists to prove, and is currently unproven by machine.

Checked and cleared
  • Status mapping. HwidSlotStatusForSubID returns found=false together with a non-nil error on every DB failure path, so err != nil wins in controller.go and a DB error is a 500, never a 404.
  • Existence oracle. The HTML info page and ?format=info already run before enforceHwid in subs(), and applyHwidHeaders already emits X-Hwid-Active: true alongside the gate 404 — a slot-less caller could distinguish a real sub id pre-PR. This endpoint discloses strictly less; not a new leak.
  • Gin tree. Inserting a static /hwid-status child under the existing :subid param node takes the wildChild branch and cannot conflict for any subPath that entity.pathHasForbiddenChar admits; /sub/<id>/ still resolves tsr = true, so the existing 301 is preserved.
  • Counter arithmetic. Remaining = max(limit-registered, 0) / Full = registered >= limit is right in the shared-sub_id case where the highest-limit_hwid client is deleted (limit 1, 3 rows -> remaining:0, full:true), and limit <= 0 yields the documented all-zero active:false shape.
  • Generator plumbing. TestRouteRegistryContract stays green because documentedContractRoutes skips registry paths starting with /{; and build-openapi.mjs only dereferences SCHEMAS[ep.responseSchema] when an entry sets responseSchema, which this one does not — so HwidSlotStatus genuinely does not need a StructAllow slot (sibling HwidGateResult has none either).

Coverage — head 01cf1f3f6c0c1fa019fa0f4bb6a34089452cba4d, 8 files, +367/-2 (~102 of those lines are the same generated OpenAPI hunk duplicated into two files).

  • internal/sub/controller.go — new hwidStatus handler + GET/HEAD routes; bypasses enforceHwid deliberately, no-cache only on 200, 404 status/body/headers identical for unknown vs disabled.
  • internal/web/service/client_hwid.go — three parameterized reads on indexed sub_id columns; no write, no transaction, no last_seen touch. Read-only, so the runtime.Runtime dispatch rule does not apply.
  • Wire format — response is exactly the five documented JSON fields, no X-Hwid-* gate header on any path. No schema or model change, so no internal/database/db.go migration is owed.
  • Endpoint chain — endpoints.ts path form matches its siblings; frontend/public/openapi.json and docs/public/openapi.json are byte-identical; the regenerated MDX operations array does contain /{subPath}{subid}/hwid-status.
  • Tests — stdlib only, t.Helper() present, exact-value assertions (body decoded into map[string]any so an extra field fails), throwaway-DB pattern reused; no comment block over two lines.
  • .github/workflows/ — untouched.
  • Unverified: everything CI would have proved (above), plus PostgreSQL behaviour — only postgres-durable-first runs against PG and it did not run. The two COUNTs and the MAX aggregate are dialect-neutral by inspection, but that is inspection, not execution. No PR code was built or run here, by design.

@Namso9

Namso9 commented Sep 1, 2026

Copy link
Copy Markdown
Author

CI evidence for 01cf1f3

The review's one open item was that no CI has run on this head — all four workflows sit at action_required. I can't approve those runs, so I reproduced every blocked job locally against 01cf1f3f6c0c1fa019fa0f4bb6a34089452cba4d with a clean tree, on go1.27.0 / Node v24.15.0 / pnpm 11.22.0 / golangci-lint 2.13.2 / PostgreSQL 17.10.

All green: race (exit 0, 0 data races), codegen/gen-check (exit 0), govulncheck (0 vulns), golangci (0 issues), fuzz-smoke (8.4M execs, no crashers), frontend (1211 tests, build + storybook + audit clean), postgres-durable-first (both steps, pass-count guards satisfied), and Docs CI (typecheck/lint/format/test/build). CodeQL Advanced is the one job I couldn't reproduce — no CodeQL CLI locally.

gen-check specifically — the reproducibility the review called unproven — now passes by machine: npm run gen reproduces frontend/public/openapi.json byte-for-byte, docs/public/openapi.json is cmp-identical to it, and cd docs && pnpm gen:api leaves git diff --exit-code -- docs/ clean.

The one go-test failure is pre-existing, not from this PR. TestEnsureReloadFallbackRestarts timed out under full-suite load. This PR touches 0 files under internal/mtproto; the test passes 5/5 in isolation and under -race; and the identical command on the unmodified base f727d04 (v3.7.0) fails more of them — TestEnsureHotReloadKeepsProcess, TestEnsureReloadFallbackRestarts, TestProcessStatusDuringExit, TestEnsureNoopKeepsProcess. Load-sensitive spawn-timing tests already flaky on main.

PostgreSQL, by execution rather than inspection. The review noted the two COUNTs and the MAX aggregate were dialect-neutral only by inspection, and CI's Postgres job wouldn't have proven it either. Since InitDB switches on XUI_DB_TYPE, I ran this PR's own tests unmodified against real PostgreSQL 17.10, each in a fresh database: all four internal/web/service TestClientHwid* (including TestClientHwidSlotStatus) and all six internal/sub TestSubscriptionHwid* pass.

Side note for whoever widens that job later: running the whole -run TestClientHwid selection against one shared PG database fails at seed time on idx_clients_email — for the pre-existing TestClientHwidGateRegistersAndBlocks too, before any of this PR's SQL runs. SQLite gives each test a throwaway file; one shared PG database keeps rows across tests in a binary. Harness artifact, not dialect behaviour.

No code changes were needed (review was 0 🔴 / 0 🟡 / 0 🟣), so the head commit is unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Read-only HWID device-slot status on the subscription API (registered / limit / remaining)

1 participant