Files

246 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Отче HTTP API: автоматизация и контракт
Базовый путь — `/api/v1`. [Машиночитаемая спецификация OpenAPI 3.1](../internal/api/openapi.json) встроена в API и публично доступна по `GET /api/v1/openapi.json`; интерфейс справки — `/docs`. JSON использует snake_case, даты RFC3339, идентификаторы UUID. Ошибка: `{ "error": { "code": "invalid_request", "message": "..." } }`. Исключения: файловые HTTP Range/conditional errors от `ServeContent` могут иметь пустое или text/plain тело, а не JSON.
Для автоматизации передавайте `Authorization: Bearer $OTCHE_API_KEY`. Ключ даёт доступ **только к данным своего владельца**, даже если выпущен администратором. Наличие Authorization имеет приоритет над cookie; неверный/истёкший/отозванный ключ никогда не переключает запрос на браузерную сессию. Не передавайте ключ в query string, имя файла, ссылку или журнал. Используйте HTTPS с проверкой сертификата (при частном CA настройте trust store/`--cacert`, не `-k`). CORS не включён. Примеры используют переменные окружения без настоящего адреса и секрета; не включайте shell tracing и не публикуйте вывод с токеном.
Браузерный альтернативный доступ — same-origin cookie `otche_session` (`HttpOnly; SameSite=Strict; Secure`, Path `/api`, срок 12 часов; HTTP допускается только явным development-флагом). `GET /auth/session` возвращает `{user:{id,username,role},csrf_token}` или 401. `POST /auth/login` с `{username,password}` требует точный Origin, создаёт cookie и возвращает тот же объект; Bearer для входа запрещён. **Все cookie-мутации требуют Origin и X-CSRF-Token**, кроме входа, когда CSRF ещё нет. `POST /auth/logout` возвращает 204, завершает только текущую сессию, не отзывает ключи. У cookie-admin сохраняется административный обзор; у API-ключа его нет. Публичны health и OpenAPI.
Malformed `{id}` path parameters return HTTP 404 `not_found` before database lookup.
## Выпуск, права и отзыв ключа
Создайте ключ в разделе «API-ключи» из обычной cookie-сессии. Выдавайте только нужные права: каждое независимо, запись **не** включает чтение.
| Право | Маршруты относительно `/api/v1` |
| --- | --- |
| `profiles:read` | `GET /profiles` |
| `jobs:read` | `GET /dashboard`, `/jobs`, `/jobs/{id}`, `/jobs/{id}/events` |
| `jobs:write` | `POST /jobs`, `/jobs/{id}/cancel`, `/jobs/{id}/retry` |
| `uploads:write` | `POST /uploads` |
| `uploads:read` | `GET /uploads/{id}/content` |
| `artifacts:read` | `GET /jobs/{id}/artifacts`, `/artifacts/{id}/content`, `/attempts/{id}/video` |
Независимость scopes относится к доступу к маршрутам и объёму ответа записи. Ключ с `jobs:write`, но **без `jobs:read`**, получает от `POST /jobs` (201), `/jobs/{id}/cancel` (200), `/jobs/{id}/retry` (200) строго `{id,status,cancel_requested}` (`JobAcknowledgement`), без настроек, Runs/Attempts, отчётов и метаданных артефактов. Cookie-сессия или ключ, у которого также есть `jobs:read`, получает полный `Job`. Право `jobs:read` включает уже вложенные в подробный Job метаданные артефактов и видео; **скачивание содержимого**, отдельный список артефактов и отдельный видео-манифест требуют `artifacts:read`. Наличие ссылки в Job не даёт разрешение её скачать.
Управление ключами **только по cookie-сессии**, не ключом:
- `GET /api-keys` → `{items:[metadata]}`, включая истёкшие/отозванные.
- `POST /api-keys` с `{name,scopes,expires_in_days}` → 201 `{key:metadata,token}`. Имя после trim — 1–80 символов без управляющих, scopes — непустой список уникальных значений из таблицы, срок — обязательное целое 1–365 дней (UI предлагает 90). Максимум 20 неистёкших неотозванных ключей на пользователя.
- `DELETE /api-keys/{id}` → 204; повтор для своего отозванного ключа идемпотентен, неизвестный/чужой id — 404.
- `metadata`: `id,name,prefix,scopes,created_at,expires_at,last_used_at,revoked_at`; последние два времени nullable. `last_used_at` обновляется с точностью до минуты после успешной аутентификации; операция после неё может завершиться 403/404.
Токен `otche_` + 64 lowercase hex показывается **единственный раз**. В БД только SHA-256 токена, prefix — первые 18 символов. Сохраните токен в секрет-хранилище; потерянный токен не восстанавливается, выпустите замену. При ротации сначала проверьте новый ключ, затем отзовите старый. Отзыв закрывает новые запросы, но не отменяет уже принятые задания и in-flight запросы. Сброс пароля администратором или отключение аккаунта навсегда отзывает все его ключи; повторное включение их не оживляет.
## Быстрый старт: curl + jq (POSIX shell)
Задайте `OTCHE_ORIGIN` (HTTPS origin без завершающего `/`), `OTCHE_API_KEY` и `SAMPLE_PATH` безопасным способом вне истории команд. Для этого примера нужны `profiles:read`, `uploads:write`, `jobs:write`, `jobs:read`, `artifacts:read`; `uploads:read` нужен только для скачивания оригинала. `curl --fail-with-body` требует современный curl; `jq` разбирает JSON. Не сохраняйте ключ в примерах/репозитории.
```sh
: "${OTCHE_ORIGIN:?}" "${OTCHE_API_KEY:?}" "${SAMPLE_PATH:?}"
API="${OTCHE_ORIGIN%/}/api/v1"
# Выберите явно один из допущенных профилей, а не случайный первый.
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" "$API/profiles" |
jq '.items[] | select(.enabled and .state == "published" and .qualification == "qualified" and .current_revision_id != null) | {id,name,online_available}'
# Установите PROFILE_ID в UUID выбранного профиля.
: "${PROFILE_ID:?}"
# X-Filename — percent-encoded basename; body — исходные байты, НЕ multipart.
FILENAME_HEADER=$(printf '%s' "${SAMPLE_PATH##*/}" | jq -sRr @uri)
UPLOAD=$(curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" \
-H 'Content-Type: application/octet-stream' -H "X-Filename: $FILENAME_HEADER" \
--data-binary "@$SAMPLE_PATH" "$API/uploads") || exit 1
UPLOAD_ID=$(printf '%s' "$UPLOAD" | jq -er '.id') || exit 1
# Полные валидные offline-настройки; DLL+rundll32 требует явного export.
REQUEST=$(jq -n --arg upload "$UPLOAD_ID" --arg profile "$PROFILE_ID" '{
upload_id:$upload, profile_ids:[$profile], settings:{
internet:"offline", allowed_cidrs:[], duration_seconds:60,
filename:"original", privilege:"user", args_mode:"none", args:[],
set_zoneid:true, dll_mode:"regsvr32", dll_export:"",
architecture:"auto", wsh_host:"cscript", msi_ui:"full", grub_paths:[]
}
}')
JOB=$(curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" -H 'Content-Type: application/json' \
--data-binary "$REQUEST" "$API/jobs") || exit 1
JOB_ID=$(printf '%s' "$JOB" | jq -er '.id') || exit 1
# Polling; это не SSE. queued/running ещё не terminal.
while :; do
JOB=$(curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" "$API/jobs/$JOB_ID") || exit 1
STATUS=$(printf '%s' "$JOB" | jq -er '.status') || exit 1
case "$STATUS" in completed|cancelled|failed) break ;; esac
sleep 3
done
printf '%s' "$JOB" | jq '.runs[] | {profile_name,attempts:[.attempts[] | {id,outcome,findings,telemetry,cleanup,error,report}]}'
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" "$API/jobs/$JOB_ID/artifacts" |
jq '.items[] | {id,kind,filename,size,sha256}'
# Установите ARTIFACT_ID в UUID нужного артефакта.
: "${ARTIFACT_ID:?}"
# Сохранение под собственным именем, без исполнения и без доверия имени артефакта.
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $OTCHE_API_KEY" \
--output ./artifact.download "$API/artifacts/$ARTIFACT_ID/content"
```
Исходник и артефакты могут быть вредоносными: не открывайте их на доверенном хосте. Размер загрузки и общая квота устанавливаются оператором; очередь/доказательства изоляции/готовность worker проверяются одинаково для UI и ключа. Принятое задание не гарантирует запуск образца: читайте `outcome`, `findings`, `telemetry`, `cleanup` и nullable отчёт, а не только `status=completed`. `not_observed` — «не наблюдалось в этом прогоне», **не** «файл безопасен».
### Ошибки, повтор и пагинация
- 401 `unauthenticated`: отсутствующий, неверный, истёкший или отозванный ключ/сессия; 401 `invalid_credentials` относится к login.
- 403 `insufficient_scope`: ключу не хватает права; `session_required`: выбран cookie-only маршрут; для cookie возможны `origin_rejected`, `csrf_rejected`.
- 400 `invalid_request`/`invalid_settings`: исправьте тело; `invalid_filename`/`empty_upload` — загрузку. 409 `key_limit` — лимит активных ключей.
- 409 `admission_closed`/`profile_unavailable`/`revision_unavailable`: реальные предпосылки выполнения не соблюдены; 409 `job_active` запрещает retry активного задания или unfinished Attempt. Смена/ослабление ключа не обходит эти проверки.
- 413 `upload_too_large`/`upload_failed`: размер или незавершённая загрузка; 429 `quota_exceeded`: квота хранилища, не новый лимитер API. Только login имеет account-based 429 `rate_limited` с `Retry-After: 900`.
- Нет idempotency-key протокола. **Не повторяйте POST автоматически** после таймаута/разрыва: операция могла выполниться. Повтор upload/create/retry может создавать новые ресурсы/попытки; сначала сверяйте доступное состояние. GET можно повторять с разумной задержкой. Не считайте каждый ответ ошибкой JSON (скачивание имеет стандартные HTTP exceptions).
- `/jobs`: по умолчанию page=1, page_size=25; размер максимум 100 (больший обрезается), page>100000 — 400. Ответ содержит `total,page,page_size,next_page`; items — краткие Job без queue_ahead/attempts. Поиск q ≤128 UTF-8 байт; имя ILIKE либо точный SHA-256, status — точное совпадение.
- `/jobs/{id}/events?after=N`: id ASC, максимум 500. Передавайте последний полученный id, пока не получите <500 элементов, затем продолжайте polling с тем же курсором. Остальные списки автоматизации не имеют page/page_size.
- Файлы поддерживают `Range: bytes=…`: 206 с Content-Range, 416 при неудовлетворимом диапазоне; conditional GET может дать 304 без тела. Обычные файлы возвращаются application/octet-stream attachment, только MP4 — video/mp4 inline.
## Длительность, online и Grub
`duration_seconds` — ровно одно из 30, 60, 90, 120, 180, 300, 600, 900, 1200. Это окно наблюдения, **не** SLA всего задания: очередь, provisioning, подготовка рабочего стола, сбор и cleanup добавляют время. Для преждевременно заблокированного образца фактические start/PID/duration могут быть null.
Начинайте с offline (без NIC). Для online нужен отдельный актуальный proof владельца, online_available профиля и явный `allowed_cidrs`. Политика неизменяема; IPv6/имена/порты/диапазоны не принимаются. `0.0.0.0/0` означает публичный IPv4, не всю сеть: защищённая инфраструктура всегда закрыта. Grub собирает только явно указанные обычные локальные Windows-файлы выбранным user/admin token после наблюдения; не произвольное чтение SYSTEM/host. Снимки best effort, не атомарные: `changed`, `missing`, `access_denied` и частичный архив — честные результаты, не Defender findings. Подробные ограничения ниже остаются частью контракта.
## Job submission and settings
`POST /uploads` body raw bytes (`application/octet-stream`), filename in `X-Filename` (encodeURIComponent). Response 201 `{id,filename,size,sha256,created_at}`. Original bytes immutable. No multipart. `GET /uploads/{id}/content` authorized attachment.
`POST /jobs` `{upload_id,profile_ids:[UUID],settings:{internet:"offline"|"online",allowed_cidrs?:string[],duration_seconds:30|60|90|120|180|300|600|900|1200,filename:"original"|"random",privilege:"user"|"admin",args_mode:"none"|"custom",args:string[],set_zoneid:boolean,dll_mode:"regsvr32"|"rundll32",dll_export:string,architecture:"auto"|"x86"|"x64",wsh_host:"cscript"|"wscript",msi_ui:"full"|"quiet"|"passive"}}` -> 201 Job. Arguments are an explicit JSON string array; only `{sample.path}` and `{sample.name}` expand in guest after path selection. DLL export mandatory with rundll32, never guessed. MSI logs always collected. Defender only; each selected profile creates exactly one Run. Immutable settings, random basename selected once per Job preserving extension.
**Strict per-job IPv4 destinations:** `online` requires `allowed_cidrs` with 1–128 nonblank IPv4 addresses/CIDRs and at most 8192 aggregate UTF-8 bytes, measured before trimming/deduplication. `offline` requires an absent or empty array; even a blank array entry is invalid. Surrounding whitespace is trimmed; bare IPv4 becomes `/32`; host bits are masked; exact canonical duplicates are removed in first-occurrence order. Overlapping entries are not collapsed. Names, address ranges, ports, IPv6 (including IPv4-mapped IPv6), leading-zero octets/prefix lengths and malformed entries are rejected with HTTP 400 `invalid_settings`. The returned immutable settings contain the normalized list.
`0.0.0.0/0` (also bare `0.0.0.0`) is a **public IPv4 only** flag, never accept-all. CIDR host-bit normalization also applies to `/0`; clients must confirm the normalized wildcard explicitly before submission. Public mode excludes `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.0.0.0/24`, `192.0.2.0/24`, `192.88.99.0/24`, `192.168.0.0/16`, `198.18.0.0/15`, `198.51.100.0/24`, `203.0.113.0/24`, `224.0.0.0/4`, and `240.0.0.0/4`, plus operator-protected/site networks. Local destinations require separate explicit prefixes, retained alongside the public flag. Explicit private/lab prefixes must be wholly within one of `10.0.0.0/8`, `100.64.0.0/10`, `172.16.0.0/12`, `192.168.0.0/16`, or `198.18.0.0/15`; non-`/0` prefixes mixing public and private space are rejected. All other excluded ranges are never allowed, and any non-`/0` prefix intersecting them is rejected, including explicit `0.0.0.0/32`.
Effective access is the requested finite destinations (or public-only flag plus explicit local destinations), minus protected infrastructure. Host management, gateway, source/control and sandbox networks configured as protected are **always denied**, even if named explicitly. Guest administrator privileges cannot override enforcement. There is no IPv6 fallback, shared clone LAN, DNS bypass or permissive startup period. DNS `1.1.1.1` is advertised only when the effective destination list permits it; otherwise DHCP advertises no resolver and no implicit system resolver is used. Windows/Defender background traffic uses the same list, with no exemption. Offline behavior remains NIC-free. The worker publishes a private `network-policy.json` artifact for the online allocation; runtime lease expiry closes flows without depending on guest cooperation.
Optional immutable `settings.grub_paths: string[]` adds **Grub** file collection after the observation window, on the selected user/admin token. Omitted/empty means no archive and remains valid for historical Jobs. At most 32 literal absolute local Windows file paths, 1024 UTF-8 bytes each / 8192 aggregate. No environment or `{sample.*}` expansion: percent/braces inside an absolute filename are literal. UNC/device/network paths, ADS, traversal, wildcards and unsafe components are rejected; actual directories, reparse points and hard links are rejected in the guest. Extensionless ordinary files are allowed. Serialized settings (including escaped arguments) must fit 38 KiB; the full worker manifest must fit the existing 40 KiB transport limit before allocation.
Each Attempt with Grub produces one private `kind: "grub_archive"`, `filename: "grub.zip"`, `content_type: "application/zip"` artifact through the existing authorized attachment URL. ZIP entries are controller-generated `files/001-sanitized_basename` etc.; duplicate basenames remain distinct. `manifest.json` contains the validated Grub report. Missing/inaccessible/oversize files are explicit errors, not Defender findings; an all-missing request produces an honest manifest-only `empty` archive. Per-file errors alone do not retain clones when ordinary evidence is complete. Transport/hash/archive/publication failure retains stopped evidence; ZIP and report metadata commit before clone deletion.
Readable live logs are captured as bounded **best-effort open-length snapshots**, not atomic filesystem snapshots. Writers that allow reads are supported; growth is not chased. Detected length/content/write-time changes yield `changed`, preserve the captured bytes when possible and make the archive `partial`. A sharing-denied file may have no snapshot. Limits: per file `min(8 MiB,max_artifact_bytes/2)`, total captured bytes `min(32 MiB,max_artifact_bytes/2)`; existing collection deadline and owner quota apply. No guest-supplied ZIP is trusted or imported.
`GET /jobs?page=1&page_size=25&q=&status=` -> `{items:Job[],total,page,page_size,next_page:null|number}`; `GET /jobs/{id}` Job with runs; `POST /jobs/{id}/cancel` -> Job; `POST /jobs/{id}/retry` -> same Job with a new Attempt for every Run (including previously successful Runs), preserving every prior Attempt and original immutable settings/revisions, admission rechecked. Retry returns HTTP 409 `job_active` if the Job is queued/running **or any of its Attempts is unfinished**, even if the stored Job status is terminal. Job `{id,owner_id,upload_id,filename,execution_filename,sha256,size,settings,status,created_at,updated_at,cancel_requested,runs:Run[]}`. Status `queued|running|completed|cancelled|failed`.
Для трёх mutation-ответов create/cancel/retry полный `Job` выше относится только к cookie-сессии или API-ключу с `jobs:read`; ключ только с `jobs:write` получает ограниченный `JobAcknowledgement` из раздела прав. GET-список возвращает `JobSummary[]` с `RunSummary[]`, без вложенных Attempts и queue_ahead.
Retry also revalidates the stored immutable network policy and current profile online availability without changing either the destination list or source revision. Historical online jobs without `allowed_cidrs` (and any other now-invalid policy) return HTTP 409 `invalid_settings`; submit a new job with explicit destinations. Missing historical destinations mean unavailable/legacy information, not unrestricted access.
Job detail additionally returns `queue_ahead: number|null`: the number of queued, never-claimed Attempts from **other Jobs** created before this Job's earliest queued, never-claimed Attempt. The count includes all owners for both operators and admins; it exposes no other Job/owner IDs. It is null when this Job has no queued, never-claimed Attempt. It counts Attempts, not Jobs, and is not a completion-time estimate.
Run `{id,job_id,profile_id,profile_name,revision_id,antivirus:"defender",status,attempts:Attempt[]}`; status `queued|running|completed|cancelled|failed`. `profile_name` is the fixed full name captured when that Run was created, including any manually entered build text; retries preserve it. Runs are ordered by `profile_name`, then `id` in detail and list/dashboard summaries; detail Attempts are ordered by `created_at` ascending.
Attempt `{id,run_id,command_id,phase,outcome,findings,telemetry,cleanup,error,created_at,claimed_at,started_at,finished_at,deadline_at,allocation,report,video,artifacts:Artifact[]}`.
- phase: `queued|provisioning|booting|recording|delivering|preparing|running|collecting|stopping|cleanup|finished`.
- outcome: `pending|executed|blocked_before_execution|incompatible|policy_blocked|delivery_error|interrupted|cancelled|error`.
- findings: `unknown|detected|not_observed`; telemetry: `pending|complete|partial|unavailable`; cleanup: `pending|complete|evidence_held|failed`.
- Timestamps are null when not applicable. Early quarantine has no actual start/PID/duration; unknown session is null, **not Session 0**.
- `claimed_at` is null until the first worker claim, then remains the first claim timestamp; it is distinct from actual sample `started_at` and includes provisioning/readiness overhead.
- `video` is the same object returned by `GET /attempts/{id}/video`: `{state:"pending"|"recording"|"complete"|"partial"|"unavailable",segments:Artifact[],gaps:[{at,reason}],started_at,finished_at}`. Clients polling Job detail need not separately poll every Attempt's video endpoint.
- allocation is null for operators; admins receive `{id,node,vmid,state}` diagnostics only, never PVE URLs or credentials.
- report is null before collection. Partially recovered data may have null environment/Defender state; UI must show N/A, not infer successful execution.
```typescript
type ExecutionReport = {
execution: {
exit_code: number | null;
error: string;
actual_duration_seconds: number | null;
started_at: string | null;
finished_at: string | null;
user: string;
session_id: number | null;
pid: number | null;
path: string;
arguments: string[];
handler: string;
privilege: string;
};
defender: {
before: DefenderState | null;
after: DefenderState | null;
drift: boolean;
detections: Detection[];
};
environment: {
os_build: string;
architecture: string;
powershell_version: string;
execution_policy: string;
runner_version: string;
} | null;
collection_errors: string[];
grub?: {
status: 'complete' | 'partial' | 'empty';
requested: number;
collected: number; // archived files, including explicitly changed snapshots
files: Array<{
requested_path: string;
resolved_path: string | null;
member: string | null;
status: 'collected' | 'missing' | 'access_denied' | 'invalid_path' | 'changed' | 'oversize' | 'error';
error: string;
size: number | null; // zero is a real captured empty file, never unknown
sha256: string | null;
snapshot: { open_size: number; changed: boolean; consistency: 'best_effort' } | null;
}>;
};
};
type DefenderState = {
active: boolean;
platform: string;
engine: string;
intelligence: string;
intelligence_updated_at: string;
fingerprint: string;
preferences: Record<string, unknown>;
};
type Detection = {
name: string;
id: string;
action: string;
resources: string[];
timestamp: string;
stage: 'delivery' | 'preparation' | 'execution' | 'collection';
source: 'defender' | 'smartscreen' | 'policy';
};
```
Missing or partial telemetry is not an empty clean report. Fixed collector fields, not arbitrary raw guest objects, populate the DTO.
`GET /jobs/{id}/events?after=N` -> `{items:[{id,job_id,attempt_id,kind,message,created_at}]}` ordered by ascending `id`, capped at **500 rows per response**. Continue with `after` set to the last returned ID until fewer than 500 rows are returned; polling supported, no PVE WebSocket exposed. `GET /jobs/{id}/artifacts` -> list Artifact `{id,job_id,attempt_id,kind,filename,content_type,size,sha256,created_at,url}`. `GET /artifacts/{id}/content` owner-authorized, supports video Range, forces attachment except safe video. HTML always attachment + sandbox. `GET /attempts/{id}/video` -> `{state:"pending"|"recording"|"complete"|"partial"|"unavailable",segments:Artifact[],gaps:[{at,reason}],started_at,finished_at}`.
## Profiles and administration
`GET /profiles` -> list Profile `{id,name,os,architecture,enabled,qualification,state,current_revision_id,online_available,reason,metadata}`; state `maintenance|published`; qualification `unqualified|qualified|drifted`; metadata is an object (empty before observations) or null after qualification without observed metadata; it contains observed OS/Defender telemetry only, no VM/token secrets. `name` is the sole editable full display label. Admission rejects unqualified/unconfigured profiles.
Windows names are entered manually, for example `Windows 11 Pro — build 26200.6584`. Neither qualification nor an OS update changes that label; only an explicit profile-name edit does. Interfaces render `Profile.name` and the immutable `Run.profile_name` verbatim. Actual `report.environment.os_build` remains technical telemetry for reports/fingerprints/drift, never a naming source. Migration freezes legacy Runs at their currently known profile name before subsequent edits; it cannot reconstruct names that were never stored and does not invent historical builds. Revisions retain separate identifiers and technical metadata, not generated build-qualified names.
Следующие `/admin/*` маршруты описаны только как справка для cookie-admin интерфейса. Они **не входят в automation OpenAPI и всегда недоступны API-ключам**, в том числе ключам администратора.
Admin: `GET /admin/users`; `POST /admin/users` `{username,password,role}`; `PATCH /admin/users/{id}` `{role?,disabled?,password?}`. User `{id,username,role,disabled,created_at}`.
`GET /admin/profiles`; `POST /admin/profiles` `{name,os,architecture}` -> Profile (maintenance, unqualified). `PATCH /admin/profiles/{id}` `{name?,enabled?}`. `POST /admin/profiles/{id}/maintenance` closes admission, returns profile plus drain status; never powers off live master. `POST /admin/profiles/{id}/publish` `{revision_id}` selects a previously worker-validated stopped source revision; never accepts VMID from browser. Returns HTTP 409 `unqualified_revision` without a passed qualification, `stale_revision` if a newer revision exists for the same source reference (including another profile), or `source_not_drained` while old-revision Attempts await cloning. `POST /admin/profiles/{id}/qualify` queues qualification, returns `{id,status}`. Qualified revision configuration and credentials are worker-only CLI/file-backed, not browser secrets.
`GET /admin/bindings` -> list `{owner_id,pool,iso_storage,disk_storage,node,configured,reason,isolation_expires_at,online_ready,online_expires_at,online_reason}` (metadata only). Expiries are nullable RFC3339: null means not validated. Effective `configured` requires successful worker validation **and** a future isolation expiry at request time. Expired/missing isolation expiry closes dashboard readiness and job admission even if a stale stored flag was true. Online submit/retry additionally requires `online_ready` **and** future `online_expires_at`, plus the selected profile's `online_available`; missing/expired online proof returns HTTP 409 `admission_closed` with a generic safe reason. The bindings response computes effective `online_ready` at read time rather than exposing a stale true flag. Missing online readiness does not disable offline admission. Bindings are provisioned by operator CLI with file-backed credentials and genuine probe evidence, no secret edits/browser storage; UI shows actual expiry/prerequisites rather than a fake credential form.
`GET /admin/health` -> `{database,worker,last_worker_seen,integration_ready,blockers:string[]}`.
`GET /admin/attempts/held` -> `{items:[{attempt_id,job_id,run_id,profile_name,filename,outcome,cleanup,error,finished_at,release_requested,allocation:{id,node,vmid,state}|null}]}`. Admin-only; finished Attempts with `cleanup:"evidence_held"|"failed"`, newest `finished_at` first (nulls last), capped at 200. Includes entries without an allocation; no PVE URLs, tokens or credentials.
`POST /admin/attempts/{id}/release-evidence` `{confirm:true}` -> HTTP 202 `{release_requested:true}` for retained Attempts with cleanup `evidence_held` or `failed` and an allocation. Explicit irreversible authorized cleanup of owned retained disposable VM/disk only, never master/control.
`GET /dashboard` owner-scoped `{metrics:{jobs,queued,running,completed,failed,cancelled,detected},recent_jobs:Job[],queue:{queued,running},integration:{ready:boolean,blockers:string[]}}`.
`GET /admin/profiles/{id}/revisions` -> `{items:[{id,profile_id,fingerprint,config_digest,qualification,created_at}]}`. Revision `qualification` is null or an object with `worker_validated:boolean`, `status:"passed"|"failed"`, `state:"qualified"|"unqualified"`, `baseline_fingerprint:string` and observed control details; fields absent before that stage must remain unknown. It is not the profile's string qualification enum.
`GET /admin/profiles/{id}/qualifications` -> `{items:[{id,profile_id,status,result,created_at}]}`; `GET /admin/qualifications/{id}` same qualification object. Status `queued|running|passed|failed`; result actual controls/attempt IDs/errors only, null until available. Qualification bypasses *qualified-profile* admission only; still requires explicit configured stopped source and isolated disposable allocation, never puts controls into master or a real Run.
## Deployment
The sibling frontend Vite build writes `dist/`; nginx reverse proxy same-origin `/api/` to Go API:8080, static UI fallback. Go API and worker separate containers/process modes. PostgreSQL, private artifact volume; PVE secret config mounted in worker only. No demo data/runtime mock adapters. Empty installs show real empty states and fail-closed integration prerequisites.
## Проверка контракта
`go test ./internal/api` включает публичную раздачу OpenAPI, HTTP-методы, разрешение schema references, независимые scopes, cookie/Bearer alternatives, required/enum/nullability и краткие/detail DTO. Интеграционные тесты реального хранения, выпуска/отзыва ключей, owner isolation и admission требуют `OTCHE_TEST_DATABASE_URL`, указывающий **только на отдельную одноразовую PostgreSQL БД**, не production. Без этой переменной database-backed тесты пропускаются; зелёный результат с skip не доказывает работу интеграции. Запуск полного `go test ./...` и frontend Playwright описан в README соответствующего sibling-репозитория.