Skip to content

Схема базы данных

  • iduuid (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 выполняется сервисным слоем в той же транзакции, что и изменение данных, — не триггерами: правило «какие изменения влияют на какие ноды» — бизнес-логика, ей место в коде, а не в схеме.
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 : ""
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 = tlscertificate_id IS NOT NULL и домен инбаунда покрыт сертификатом;
  • порт уникален в пределах ноды по объединению inbounds.port ∪ chain_hops.port — два UNIQUE в разных таблицах не защищают от коллизии инбаунда и хопа на одной ноде.

FK chain_hops.node_id без каскада — удаление ноды, участвующей в цепочке, отклоняется, пока цепочка не перестроена.

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) копируются в подписку при активации — снапшот сделки, см. Клиенты и подписки.

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 в одной транзакции (Трафик и лимиты).

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
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-ем.

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, …) — в схему приложения не входят.

Goose, SQL-файлы в go:embed, последовательная нумерация (порядок мержа — источник истины). Backend применяет миграции на старте под advisory lock (отключаемо: ASTRAL_AUTO_MIGRATE=false + команда astral migrate для раздельной раскатки). Ломающие изменения — двухфазно: аддитивная миграция + код, пишущий в оба места → удаляющая миграция следующим релизом.