Схема базы данных
Соглашения
Section titled “Соглашения”id—uuid(v7: сортируемость по времени, индекс-friendly), кромеaudit_log(bigint).created_at/updated_atприсутствуют везде и отдельно не описываются.- 🔒 — поле с envelope-шифрованием: тройка
<имя>_ciphertext,<имя>_nonce,<имя>_data_key_id → data_keys.id(Шифрование). - FK без пометки —
ON DELETE RESTRICT. Каскады указаны явно. - Инкремент
nodes.desired_revisionвыполняется сервисным слоем в той же транзакции, что и изменение данных, — не триггерами: правило «какие изменения влияют на какие ноды» — бизнес-логика, ей место в коде, а не в схеме.
Обзор связей
Section titled “Обзор связей”erDiagram
clients ||--o{ subscriptions : ""
plans ||--o{ subscriptions : "SET NULL"
access_groups ||--o{ subscriptions : ""
access_groups ||--o{ access_group_inbounds : ""
inbounds ||--o{ access_group_inbounds : ""
subscriptions ||--o{ protocol_credentials : ""
subscriptions ||--o{ traffic_usage : ""
nodes ||--o{ inbounds : ""
inbound_profiles ||--o{ inbounds : ""
certificates ||--o{ inbounds : ""
chains ||--o{ inbounds : "entry"
chains ||--o{ chain_hops : ""
nodes ||--o{ chain_hops : ""
dns_providers ||--o{ certificates : ""
acme_accounts ||--o{ certificates : ""
clients ||--o{ payments : ""
payment_providers ||--o{ payments : ""
Инфраструктура
Section titled “Инфраструктура”nodes├── id uuid PK├── name text UNIQUE├── public_address text -- попадает в клиентские конфиги и аутбаунды хопов├── admin_status text -- provisioning | active | disabled├── block_torrent bool DEFAULT true├── desired_revision bigint DEFAULT 0├── applied_revision bigint DEFAULT 0├── applied_error text NULL -- последняя ошибка применения от агента├── last_seen_at timestamptz NULL├── agent_version text NULL -- сообщает агент├── xray_version text NULL├── enroll_token_hash text NULL -- одноразовый, NULL после enrollment├── enroll_token_expires_at timestamptz NULL└── cert_fingerprint text NULL -- SHA-256 клиентского сертификата агента
inbound_profiles├── id uuid PK├── name text UNIQUE├── protocol text -- vless | vmess | trojan | shadowsocks├── network text -- tcp | ws | grpc | httpupgrade | xhttp├── security text -- none | tls | reality└── settings jsonb -- параметры транспорта/безопасности; 🔒 reality private key внутри — отдельной колонкой: 🔒 reality_private_key
inbounds├── id uuid PK├── node_id uuid FK → nodes ON DELETE CASCADE├── profile_id uuid FK → inbound_profiles├── port int├── tag text├── certificate_id uuid FK → certificates NULL├── chain_id uuid FK → chains NULL├── enabled bool DEFAULT true├── UNIQUE (node_id, port)└── UNIQUE (node_id, tag)
chains├── id uuid PK└── name text UNIQUE
chain_hops├── id uuid PK├── chain_id uuid FK → chains ON DELETE CASCADE├── node_id uuid FK → nodes├── position int -- 0 … N-1 (N-1 = exit)├── port int├── 🔒 secret -- учётка, которой предыдущий хоп подключается├── 🔒 reality_private_key├── reality_public_key text├── short_id text├── dest text -- сайт-прикрытие Reality└── UNIQUE (chain_id, position)Межтабличные инварианты сервисного слоя (в CHECK/UNIQUE не выражаются):
security = tls⇒certificate_id IS NOT NULLи домен инбаунда покрыт сертификатом;- порт уникален в пределах ноды по объединению
inbounds.port ∪ chain_hops.port— два UNIQUE в разных таблицах не защищают от коллизии инбаунда и хопа на одной ноде.
FK chain_hops.node_id без каскада — удаление ноды, участвующей в цепочке, отклоняется, пока цепочка не перестроена.
Клиенты и подписки
Section titled “Клиенты и подписки”clients├── id uuid PK├── email citext UNIQUE├── name text NULL├── password_hash text NULL -- Argon2id; NULL — вход без пароля├── status text -- pending | active | rejected | disabled└── email_verified_at timestamptz NULL -- проставляется первым использованием magic link -- или OAuth-профилем с email_verified
client_oauth_identities├── id uuid PK├── client_id uuid FK → clients ON DELETE CASCADE├── provider text├── provider_user_id text└── UNIQUE (provider, provider_user_id)
access_groups├── id uuid PK└── name text UNIQUE
access_group_inbounds├── access_group_id uuid FK → access_groups ON DELETE CASCADE├── inbound_id uuid FK → inbounds ON DELETE CASCADE└── PK (access_group_id, inbound_id)
plans├── id uuid PK├── name text UNIQUE├── price numeric(12,2)├── currency char(3)├── period interval -- срок, добавляемый при покупке├── traffic_limit_bytes bigint NULL├── traffic_reset text -- none | monthly├── access_group_id uuid FK → access_groups└── archived_at timestamptz NULL -- архивный план не продаётся, подписки живут
subscriptions├── id uuid PK├── client_id uuid FK → clients ON DELETE CASCADE├── plan_id uuid FK → plans ON DELETE SET NULL├── access_group_id uuid FK → access_groups├── status text -- active | expired | suspended | disabled├── expires_at timestamptz NULL├── traffic_limit_bytes bigint NULL├── traffic_reset text├── traffic_used_bytes bigint DEFAULT 0 -- материализованный счётчик периода, см. Трафик├── current_period_started_at timestamptz├── 🔒 token -- секрет ссылки /sub/{token}; token_hash text UNIQUE — для поиска└── INDEX (status, expires_at) -- выборка истекающих
protocol_credentials├── id uuid PK├── subscription_id uuid FK → subscriptions ON DELETE CASCADE├── protocol text├── 🔒 secret└── UNIQUE (subscription_id, protocol)Поиск подписки по токену — по детерминированному token_hash (SHA-256); сам токен хранится 🔒, чтобы ссылку можно было показать повторно. Условия плана (access_group_id, traffic_limit_bytes, traffic_reset) копируются в подписку при активации — снапшот сделки, см. Клиенты и подписки.
Трафик
Section titled “Трафик”traffic_usage -- почасовая, retention 30 дней├── subscription_id uuid FK → subscriptions ON DELETE CASCADE├── node_id uuid FK → nodes ON DELETE CASCADE├── bucket timestamptz -- усечён до часа├── uplink_bytes bigint├── downlink_bytes bigint└── PK (subscription_id, node_id, bucket)
traffic_usage_daily -- retention 12 месяцев├── subscription_id uuid FK → subscriptions ON DELETE CASCADE├── bucket_date date├── uplink_bytes bigint├── downlink_bytes bigint└── PK (subscription_id, bucket_date)
node_usage_cursors -- идемпотентность отчётов├── node_id uuid PK FK → nodes ON DELETE CASCADE└── last_report_id bigintСуточная таблица агрегирована по подпискам без размерности ноды: долгосрочно важен расход подписки, а не разбивка по нодам. Удаление ноды теряет только ≤30 дней её почасовой детализации. Приём отчётов: upsert bytes += delta + продвижение last_report_id в одной транзакции (Трафик и лимиты).
Сертификаты
Section titled “Сертификаты”certificates├── id uuid PK├── kind text -- acme | manual├── domains text[]├── acme_account_id uuid FK → acme_accounts NULL -- NOT NULL при kind=acme (CHECK)├── dns_provider_id uuid FK → dns_providers NULL -- NOT NULL при kind=acme (CHECK)├── cert_pem text NULL -- публичная цепочка, не секрет├── 🔒 key_pem├── expires_at timestamptz NULL├── status text -- pending | active | renewal_error└── last_error text NULL
dns_providers├── id uuid PK├── name text UNIQUE├── type text -- cloudflare | route53 | ...├── zone text└── 🔒 credentials
acme_accounts├── id uuid PK├── name text UNIQUE├── directory_url text├── 🔒 account_key├── eab_kid text NULL└── 🔒 eab_hmac -- NULL, если CA не требует EAB
internal_ca -- CA для mTLS агентов, ровно одна строка├── id bool PK DEFAULT true CHECK (id)├── cert_pem text└── 🔒 key_pemПлатежи
Section titled “Платежи”payment_providers├── id uuid PK├── type text -- stripe | yookassa | cryptomus | ...├── name text UNIQUE├── enabled bool└── 🔒 credentials
payments├── id uuid PK├── client_id uuid FK → clients├── plan_id uuid FK → plans ON DELETE SET NULL├── subscription_id uuid FK → subscriptions ON DELETE SET NULL -- какая подписка создана/продлена├── provider_id uuid FK → payment_providers├── external_id text -- id транзакции у агрегатора├── amount numeric(12,2)├── currency char(3)├── status text -- pending | paid | failed | refunded└── UNIQUE (provider_id, external_id) -- идемпотентность вебхуковАутентификация и аудит
Section titled “Аутентификация и аудит”admins├── id uuid PK├── email citext UNIQUE├── password_hash text NULL -- Argon2id; NULL — только OAuth└── role text -- owner | admin | viewer
admin_oauth_identities├── id uuid PK├── admin_id uuid FK → admins ON DELETE CASCADE├── provider text├── provider_user_id text└── UNIQUE (provider, provider_user_id)
sessions -- refresh-токены админов и клиентов├── id uuid PK├── subject_type text -- admin | client├── subject_id uuid├── token_hash text UNIQUE├── expires_at timestamptz├── revoked_at timestamptz NULL└── INDEX (subject_type, subject_id)
magic_links├── token_hash text PK├── client_id uuid FK → clients ON DELETE CASCADE├── expires_at timestamptz└── used_at timestamptz NULL
audit_log -- append-only, партиционирование по месяцам├── id bigint PK (identity)├── actor_type text -- admin | client | system├── actor_id uuid NULL├── action text -- "node.create", "subscription.suspend", ...├── entity_type text├── entity_id uuid NULL├── payload jsonb -- diff/контекст├── created_at timestamptz└── INDEX (entity_type, entity_id), INDEX (created_at)sessions.subject_id не имеет FK намеренно: полиморфная ссылка на две таблицы. Валидность субъекта гарантирует выдача токена; осиротевшие сессии вычищаются по expires_at.
Аудит и история состояния. Концептуально это разные вопросы: аудит отвечает «кто и что сделал» (включая действия без изменения состояния — неудачный вход, отклонённая валидация), история состояния — «каким стало состояние после перехода» (включая переходы без человека — автопродление сертификата с actor_type = system). Физически сегодня обе роли обслуживает audit_log: переходы состояния — подмножество записей с payload-diff. Разделение на две таблицы — аддитивная миграция, выполняемая при наступлении хотя бы одного из условий:
- разный retention;
- разные права доступа;
- разные требования к поиску/индексам;
- разные источники записи;
- разные требования к неизменяемости.
Критерий зафиксирован, чтобы решение о разделении было проверкой условия, а не повторным обсуждением (ADR-004).
Реконструкция состояния на произвольную прошлую ревизию не поддерживается намеренно: replay по логу изменений превращает лог во второй источник истины, полнота которого становится критичной для каждого write-пути (фактически Event Sourcing). Откат выполняется как новое изменение по diff из аудита. Если появится задача «что именно работало на ноде в ревизии N» — она решается сохранением отрендеренного NodeConfig в момент применения (ограниченный объём, явный retention), а не replay-ем.
Шифрование
Section titled “Шифрование”data_keys├── id uuid PK├── scope text -- "credentials", "certificates", "node_secrets", ...├── wrapped_dek bytea -- DEK, обёрнутый KMS-провайдером├── kms_key_version text├── created_at timestamptz└── retired_at timestamptz NULL
key_recovery -- recovery-слот мастер-ключа (провайдер local), ровно одна строка├── id bool PK DEFAULT true CHECK (id)├── wrapped_master_key bytea -- мастер-ключ, обёрнутый ключом из recovery-кода├── kdf_params jsonb -- параметры Argon2id + соль└── created_at timestamptzДетали — Шифрование. Таблицы очереди фоновых задач создаёт и владеет ими River (river_job, …) — в схему приложения не входят.
Миграции
Section titled “Миграции”Goose, SQL-файлы в go:embed, последовательная нумерация (порядок мержа — источник истины). Backend применяет миграции на старте под advisory lock (отключаемо: ASTRAL_AUTO_MIGRATE=false + команда astral migrate для раздельной раскатки). Ломающие изменения — двухфазно: аддитивная миграция + код, пишущий в оба места → удаляющая миграция следующим релизом.