Files

33 KiB
Raw Permalink Blame History

Отче HTTP API: автоматизация и контракт

Базовый путь — /api/v1. Машиночитаемая спецификация OpenAPI 3.1 встроена в 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. Не сохраняйте ключ в примерах/репозитории.

: "${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.
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-репозитория.