246 lines
33 KiB
Markdown
246 lines
33 KiB
Markdown
# Отче 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-репозитория.
|