Агент свідомо перейменував вихідні колонки функції на devices_used, maps_used, agents_used, users_used — щоб `devices` поруч із таблицею inv.devices не давало неоднозначності. Запит у Go лишився на старих іменах. Код збирався, локальні тести були зелені (пропускались без NETPULSE_TEST_DSN), а перше ж звернення до бази падало з «column "devices" does not exist». Сторінка білінгу не показала б нічого. Знайдено пісочницею: установка зі свіжого клону, 63 міграції на чистій базі, потім тести з живою БД. Це той самий клас, що ловився весь день — зелена перевірка доводить рівно те, що перевіряє, а тест, який мовчки пропускається, не перевіряє нічого. Заодно записано в шапці api_test.go умову, якої той набір вимагає: база має бути порожньою. Користувачі в продукті глобальні, і на вже поставленій системі seedUser натрапляє на власника admin.
850 lines
37 KiB
Go
850 lines
37 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
)
|
||
|
||
// Тарифи, стелі й підписка: те, що показують людині, і те, що читає
|
||
// перевірка лімітів.
|
||
//
|
||
// ЩО ТУТ Є ДЖЕРЕЛОМ ПРАВДИ
|
||
//
|
||
// Рівно одна таблиця — bill.entitlements. Її читають тригери в БД, її
|
||
// читає сторінка, з неї беруться числа у відмові «дозволено 15, зараз
|
||
// 15». Усе інше (тариф, ліцензійний ключ, майбутня підписка Stripe) —
|
||
// це джерела, які в неї ПИШУТЬ, і жодне з них не має права бути
|
||
// прочитаним у мить перевірки.
|
||
//
|
||
// Це не стилістика. Перевірка ліміту виконується на кожній вставці
|
||
// хоста, а вставок буває тисяча за прогін автовиявлення; JOIN по
|
||
// чотирьох таблицях із розбором JSON у кожній із них там неприпустимий.
|
||
// І друге, важливіше: перевірка, яка знає про Stripe, ламається разом зі
|
||
// Stripe. Той, хто дописуватиме платіжку, не повинен мати способу
|
||
// зачепити відповідь на питання «чи можна завести хост».
|
||
//
|
||
// ЩО РОБИТЬ ЦЕЙ ФАЙЛ
|
||
//
|
||
// Читає стан для сторінки й перекладає відмову БД у щось, що можна
|
||
// показати людині. Перерахунок стель із ліцензії живе поруч, у
|
||
// billing_license.go — там, де й решта ліцензійної логіки.
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Відмова через ліміт
|
||
// ---------------------------------------------------------------------
|
||
|
||
// ЧОМУ ВІДМОВА ЧЕРЕЗ СТЕЛЮ — ТИП, А НЕ РЯДОК
|
||
//
|
||
// Тому що клієнт має отримати числа, а не фразу. «Вичерпано ліміт
|
||
// пристроїв тарифу» — це те, що людина вже й так зрозуміла з того, що
|
||
// нічого не створилось; корисне починається з «дозволено 15, зараз 15»,
|
||
// бо саме воно каже, що робити далі, і саме воно доводить, що система
|
||
// не зламалась, а відмовила свідомо.
|
||
//
|
||
// Числа приходять із DETAIL відмови (див. bill.deny_limit у 0069) саме
|
||
// тому, що вони мають прийти З ТІЄЇ Ж транзакції, яка відмовила.
|
||
// Порахувати їх другим запитом означало б показати інше число, ніж те,
|
||
// на якому спрацювала перевірка.
|
||
|
||
// PlanLimitError — стеля тарифу вичерпана.
|
||
type PlanLimitError struct {
|
||
// Kind — що саме скінчилось: devices | maps | map_nodes | agents | users.
|
||
Kind string `json:"limit"`
|
||
Plan string `json:"plan"`
|
||
Allowed int `json:"allowed"`
|
||
Used int `json:"used"`
|
||
|
||
// message — фраза, яку склала сама БД, українською й з числами.
|
||
// Складати її вдруге в Go означало б мати два формулювання однієї
|
||
// відмови: одне в логах Postgres, інше на екрані.
|
||
message string
|
||
}
|
||
|
||
func (e *PlanLimitError) Error() string {
|
||
if e.message != "" {
|
||
return e.message
|
||
}
|
||
return "досягнуто ліміт тарифу"
|
||
}
|
||
|
||
// Is прив'язує тип до наявного вартового ErrPlanLimit.
|
||
//
|
||
// Щоб код, який уже питає errors.Is(err, ErrPlanLimit) (шлях правки
|
||
// мапи, де відмову перекладає mapPgError), продовжував працювати й тоді,
|
||
// коли до нього дійде вже типізована помилка.
|
||
//
|
||
// Чого це НЕ робить, і про це варто сказати вголос: сира помилка бази
|
||
// вартовому не відповідає — у її ланцюжку лежить *pgconn.PgError, а не
|
||
// цей тип. Тому спільний обробник помилок питає ще й AsPlanLimit
|
||
// окремим рядком (httpapi/maps_write.go). Спокуса покластись на одну
|
||
// перевірку тут коштувала б 500 на всіх шляхах, крім мап.
|
||
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
||
|
||
// AsPlanLimit впізнає відмову через стелю в будь-якій обгортці.
|
||
//
|
||
// Впізнавання йде за HINT = 'upgrade_plan', а НЕ за текстом помилки.
|
||
// Так було в maps_write.go з самого початку, і так правильно: текст
|
||
// відмови — це те, що переписують, коли він погано читається. Саме на
|
||
// тексті й зламалась попередня перевірка (httpapi/groups.go шукав слово
|
||
// «ліміт» у повідомленні, яке 0009 писала англійською), і зламалась
|
||
// мовчки — до першого спрацювання стелі ніхто б і не дізнався.
|
||
func AsPlanLimit(err error) (*PlanLimitError, bool) {
|
||
if err == nil {
|
||
return nil, false
|
||
}
|
||
var already *PlanLimitError
|
||
if errors.As(err, &already) {
|
||
return already, true
|
||
}
|
||
var pgErr *pgconn.PgError
|
||
if !errors.As(err, &pgErr) || pgErr.Hint != "upgrade_plan" {
|
||
return nil, false
|
||
}
|
||
out := &PlanLimitError{message: pgErr.Message}
|
||
// Подробиця може не розібратись — наприклад, на інсталяції, де
|
||
// 0069 ще не накотилась і тригер лишився з 0009. Це не привід
|
||
// втрачати саму відмову: без чисел вона гірша, але вона є, і 402
|
||
// має поїхати замість 500 у будь-якому разі.
|
||
if pgErr.Detail != "" {
|
||
_ = json.Unmarshal([]byte(pgErr.Detail), out)
|
||
}
|
||
return out, true
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Тарифи
|
||
// ---------------------------------------------------------------------
|
||
|
||
// Plan — тариф так, як його бачить сторінка.
|
||
type Plan struct {
|
||
Key string `json:"key"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
BasePriceCents int `json:"base_price_cents"`
|
||
PerDeviceCents int `json:"per_device_cents"`
|
||
Currency string `json:"currency"`
|
||
BillingPeriod string `json:"billing_period"`
|
||
PlanLimits
|
||
Features []string `json:"features"`
|
||
IsPublic bool `json:"is_public"`
|
||
}
|
||
|
||
// Feature — можливість із каталогу bill.features.
|
||
type Feature struct {
|
||
Key string `json:"key"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
}
|
||
|
||
// ListPlans — усі тарифи, разом із непублічними.
|
||
//
|
||
// Непублічні теж, бо один із них (self_hosted) майже напевно й буде
|
||
// поточним: сторінка мусить уміти назвати те, що зараз діє. Ховає їх зі
|
||
// СПИСКУ ПРОПОЗИЦІЙ уже інтерфейс — за прапорцем is_public, а не за
|
||
// відсутністю рядка.
|
||
func (s *Store) ListPlans(ctx context.Context) ([]Plan, error) {
|
||
rows, err := s.pool.Query(ctx, `
|
||
SELECT key, name, COALESCE(description,''), base_price_cents, per_device_cents,
|
||
currency, billing_period,
|
||
max_devices, max_maps, max_map_nodes, max_agents, max_users,
|
||
metric_retention_days, features, is_public
|
||
FROM bill.plans ORDER BY sort_order, key
|
||
`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []Plan{}
|
||
for rows.Next() {
|
||
var p Plan
|
||
if err := rows.Scan(&p.Key, &p.Name, &p.Description, &p.BasePriceCents,
|
||
&p.PerDeviceCents, &p.Currency, &p.BillingPeriod,
|
||
&p.MaxDevices, &p.MaxMaps, &p.MaxMapNodes, &p.MaxAgents, &p.MaxUsers,
|
||
&p.RetentionDays, &p.Features, &p.IsPublic); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ListFeatures — каталог можливостей.
|
||
func (s *Store) ListFeatures(ctx context.Context) ([]Feature, error) {
|
||
rows, err := s.pool.Query(ctx,
|
||
`SELECT key, name, COALESCE(description,'') FROM bill.features ORDER BY key`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []Feature{}
|
||
for rows.Next() {
|
||
var f Feature
|
||
if err := rows.Scan(&f.Key, &f.Name, &f.Description); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, f)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Стелі кабінету
|
||
// ---------------------------------------------------------------------
|
||
|
||
// Entitlement — чинні стелі кабінету.
|
||
type Entitlement struct {
|
||
PlanKey string `json:"plan_key"`
|
||
PlanName string `json:"plan_name"`
|
||
PlanLimits
|
||
Features []string `json:"features"`
|
||
Source string `json:"source"`
|
||
Reason string `json:"reason"`
|
||
ValidUntil *time.Time `json:"valid_until,omitempty"`
|
||
GraceUntil *time.Time `json:"grace_until,omitempty"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// Has — чи входить можливість у стелі кабінету.
|
||
func (e *Entitlement) Has(feature string) bool {
|
||
for _, f := range e.Features {
|
||
if f == feature {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ЧОМУ СТЕЛІ ЗАВОДЯТЬСЯ ЛІНИВО, А НЕ ТРИГЕРОМ НА core.tenants
|
||
//
|
||
// 0069 завела рядок кожному кабінету, який існував на мить накочування.
|
||
// Кабінети, створені ПІСЛЯ (netpulse-user, майбутня реєстрація),
|
||
// лишаються без рядка — і це нормальний стан, а не поломка: стелі там
|
||
// немає, тригери пропускають усе, продукт працює.
|
||
//
|
||
// Але сторінка тарифу в такому кабінеті мусить щось показати, і
|
||
// показати вона мусить правду: «стелі не задані». Заводити рядок звідси
|
||
// дешевше й чесніше, ніж вішати ще один тригер на створення кабінету:
|
||
// тригер спрацював би в мить, коли про тариф ще ніхто не питав, і його
|
||
// довелося б тримати в синхроні з переліком колонок ще в одному місці.
|
||
|
||
// TenantEntitlement читає стелі, заводячи їх за потреби.
|
||
func (s *Store) TenantEntitlement(ctx context.Context, tenantID string) (*Entitlement, error) {
|
||
e, err := s.readEntitlement(ctx, tenantID)
|
||
if err == nil || !errors.Is(err, ErrNotFound) {
|
||
return e, err
|
||
}
|
||
if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO bill.entitlements
|
||
(tenant_id, plan_key, max_devices, max_maps, max_map_nodes, max_agents,
|
||
max_users, metric_retention_days, features, source, reason)
|
||
SELECT $1, p.key, p.max_devices, p.max_maps, p.max_map_nodes, p.max_agents,
|
||
p.max_users, p.metric_retention_days, p.features, 'license_key', 'migration'
|
||
FROM bill.plans p WHERE p.key = 'self_hosted'
|
||
ON CONFLICT (tenant_id) DO NOTHING
|
||
`, tenantID)
|
||
return err
|
||
}); err != nil {
|
||
return nil, err
|
||
}
|
||
return s.readEntitlement(ctx, tenantID)
|
||
}
|
||
|
||
func (s *Store) readEntitlement(ctx context.Context, tenantID string) (*Entitlement, error) {
|
||
var e Entitlement
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
SELECT e.plan_key, COALESCE(p.name, e.plan_key),
|
||
e.max_devices, e.max_maps, e.max_map_nodes, e.max_agents, e.max_users,
|
||
e.metric_retention_days, e.features, e.source::text, e.reason,
|
||
e.valid_until, e.grace_until, e.updated_at
|
||
FROM bill.entitlements e
|
||
LEFT JOIN bill.plans p ON p.key = e.plan_key
|
||
WHERE e.tenant_id = $1
|
||
`, tenantID).Scan(&e.PlanKey, &e.PlanName,
|
||
&e.MaxDevices, &e.MaxMaps, &e.MaxMapNodes, &e.MaxAgents, &e.MaxUsers,
|
||
&e.RetentionDays, &e.Features, &e.Source, &e.Reason,
|
||
&e.ValidUntil, &e.GraceUntil, &e.UpdatedAt)
|
||
})
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if e.Features == nil {
|
||
e.Features = []string{}
|
||
}
|
||
return &e, nil
|
||
}
|
||
|
||
// TenantUsage — скільки слотів зайнято.
|
||
//
|
||
// Питається в БД тією самою функцією, яку викликають тригери
|
||
// (bill.usage_now), а не власним count(*). Друге визначення «зайнятого
|
||
// хоста» неминуче розійшлося б із першим — і розійшлося б саме там, де
|
||
// це найдорожче: сторінка казала б «14 з 15», а вставка відмовляла б.
|
||
func (s *Store) TenantUsage(ctx context.Context, tenantID string) (Usage, error) {
|
||
var u Usage
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx,
|
||
// Суфікс `_used` — не косметика: функція повертає колонки з
|
||
// цими іменами саме тому, що `devices` поруч із таблицею
|
||
// inv.devices дає неоднозначність (пояснення в 0069). Тут
|
||
// імена спершу лишились старими, і це знайшлось лише в
|
||
// пісочниці: код збирався, а перший же запит до бази падав
|
||
// з «column "devices" does not exist».
|
||
`SELECT devices_used, maps_used, agents_used, users_used
|
||
FROM bill.usage_now($1)`, tenantID).
|
||
Scan(&u.Devices, &u.Maps, &u.Agents, &u.Users)
|
||
})
|
||
return u, err
|
||
}
|
||
|
||
// SetTenantPlan переводить кабінет на тариф.
|
||
//
|
||
// Ручна дія власника — те, чим користуються, поки платіжки немає, і те,
|
||
// чим користуватимуться після неї для enterprise-домовленостей. Пише в
|
||
// bill.entitlements напряму, з source = 'manual': видно, що стелі
|
||
// поставила людина, а не ключ і не провайдер.
|
||
//
|
||
// Стеля нижча за зайняте не забороняється, і це свідомо. Власник має
|
||
// право перевести кабінет на менший тариф, маючи 40 хостів при стелі
|
||
// 15: наявні 40 лишаються під наглядом (тригер стоїть на появі нового,
|
||
// не на існуванні), нові не заводяться, а на сторінці стоїть «40 з 15»
|
||
// — тобто рівно та правда, за якою вирішують, що робити далі.
|
||
// Заборонити перехід означало б вимагати спершу видалити 25 хостів,
|
||
// щоб дізнатись, чи влаштує дешевший тариф.
|
||
func (s *Store) SetTenantPlan(ctx context.Context, tenantID, planKey, userID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE bill.entitlements e SET
|
||
plan_key = p.key,
|
||
max_devices = p.max_devices, max_maps = p.max_maps,
|
||
max_map_nodes = p.max_map_nodes, max_agents = p.max_agents,
|
||
max_users = p.max_users,
|
||
metric_retention_days = p.metric_retention_days,
|
||
features = p.features,
|
||
source = 'manual', reason = 'plan', license_id = NULL,
|
||
valid_until = NULL, grace_until = NULL,
|
||
updated_at = now()
|
||
FROM bill.plans p
|
||
WHERE e.tenant_id = $1 AND p.key = $2
|
||
`, tenantID, planKey)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return fmt.Errorf("%w: тариф %q не існує або кабінету ще не заведено стель",
|
||
ErrInvalid, planKey)
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Ліцензія інсталяції
|
||
// ---------------------------------------------------------------------
|
||
|
||
// instanceRow — сирий рядок bill.instance.
|
||
type instanceRow struct {
|
||
InstallID string
|
||
LicenseKey string
|
||
Payload []byte
|
||
State string
|
||
InvalidReason string
|
||
ClockMaxSeen time.Time
|
||
ClockWarpedAt *time.Time
|
||
CheckedAt *time.Time
|
||
AppliedAt *time.Time
|
||
}
|
||
|
||
// readInstance читає рядок інсталяції.
|
||
//
|
||
// Пулом воркера, як і core.storage_config: таблиця рівня інсталяції,
|
||
// tenant_id у неї немає за побудовою, і RLS до неї не застосовна.
|
||
func (s *Store) readInstance(ctx context.Context) (*instanceRow, error) {
|
||
var r instanceRow
|
||
var key, reason *string
|
||
err := s.bg.QueryRow(ctx, `
|
||
SELECT install_id::text, license_key, payload, state, invalid_reason,
|
||
clock_max_seen, clock_warped_at, checked_at, applied_at
|
||
FROM bill.instance WHERE id
|
||
`).Scan(&r.InstallID, &key, &r.Payload, &r.State, &reason,
|
||
&r.ClockMaxSeen, &r.ClockWarpedAt, &r.CheckedAt, &r.AppliedAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if key != nil {
|
||
r.LicenseKey = *key
|
||
}
|
||
if reason != nil {
|
||
r.InvalidReason = *reason
|
||
}
|
||
return &r, nil
|
||
}
|
||
|
||
// InstallID — ідентифікатор цієї інсталяції.
|
||
//
|
||
// Потрібен людині: саме його вона надсилає, замовляючи ключ.
|
||
func (s *Store) InstallID(ctx context.Context) (string, error) {
|
||
r, err := s.readInstance(ctx)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return r.InstallID, nil
|
||
}
|
||
|
||
// LicenseStatus — поточний стан без перерахунку.
|
||
//
|
||
// Дешеве читання для сторінки. Стан перераховує такт (RefreshLicense);
|
||
// сторінка показує те, що він порахував, і поруч — коли саме. Рахувати
|
||
// на кожному відкритті означало б, що стан залежить від того, чи хтось
|
||
// дивиться.
|
||
func (s *Store) LicenseStatus(ctx context.Context, v *LicenseVerifier) (LicenseState, error) {
|
||
r, err := s.readInstance(ctx)
|
||
if err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
st := LicenseState{
|
||
State: r.State,
|
||
Reason: r.InvalidReason,
|
||
InstallID: r.InstallID,
|
||
CheckedAt: r.CheckedAt,
|
||
AppliedAt: r.AppliedAt,
|
||
ClockWarped: r.ClockWarpedAt != nil,
|
||
ClockMaxSeen: &r.ClockMaxSeen,
|
||
VerifierEmpty: v.Empty(),
|
||
}
|
||
if len(r.Payload) == 0 {
|
||
return st, nil
|
||
}
|
||
var p LicensePayload
|
||
if err := json.Unmarshal(r.Payload, &p); err != nil {
|
||
return st, nil
|
||
}
|
||
live := EvaluateLicense(&p, nowFor(r))
|
||
// Стан беремо з перерахованого, а не з колонки: колонка відстає
|
||
// рівно на час до наступного такту, і в добу переходу active→grace
|
||
// сторінка показувала б учорашню правду.
|
||
//
|
||
// Виняток — invalid: він означає, що payload не пройшов перевірку
|
||
// підпису, і будувати з нього строк не можна взагалі.
|
||
if r.State != LicenseInvalid {
|
||
live.InstallID = st.InstallID
|
||
live.CheckedAt, live.AppliedAt = st.CheckedAt, st.AppliedAt
|
||
live.ClockWarped, live.ClockMaxSeen = st.ClockWarped, st.ClockMaxSeen
|
||
live.VerifierEmpty = st.VerifierEmpty
|
||
return live, nil
|
||
}
|
||
st.LicenseID, st.IssuedTo, st.Plan = p.LicenseID, p.IssuedTo, p.Plan
|
||
return st, nil
|
||
}
|
||
|
||
// nowFor — час, за яким рахувати строк для цього рядка інсталяції.
|
||
func nowFor(r *instanceRow) time.Time {
|
||
t, _ := effectiveNow(time.Now().UTC(), r.ClockMaxSeen)
|
||
return t
|
||
}
|
||
|
||
// ApplyLicense застосовує введений ключ.
|
||
//
|
||
// Порядок: перевірити підпис → перевірити прив'язку до інсталяції →
|
||
// записати → перерахувати стелі. Перший крок, що не вдався, лишає все
|
||
// як було: інсталяція з чинним ключем не має втратити його від того,
|
||
// що хтось вставив у поле сторонній рядок.
|
||
func (s *Store) ApplyLicense(ctx context.Context, v *LicenseVerifier, key, userID string) (LicenseState, error) {
|
||
inst, err := s.readInstance(ctx)
|
||
if err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
|
||
p, perr := v.ParseLicense(key)
|
||
if perr != nil {
|
||
return LicenseState{}, perr
|
||
}
|
||
// Прив'язка. Порожній InstallID у ключі — ключ «на пред'явника»:
|
||
// його випустили до розгортання, і забирає його той, хто ввів
|
||
// першим. Непорожній мусить збігтися дослівно.
|
||
if p.InstallID != "" && p.InstallID != inst.InstallID {
|
||
return LicenseState{}, fmt.Errorf("%w: ключ виданий для %s, ця інсталяція — %s",
|
||
ErrLicenseInstall, p.InstallID, inst.InstallID)
|
||
}
|
||
|
||
body, err := json.Marshal(p)
|
||
if err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
now := time.Now().UTC()
|
||
st := EvaluateLicense(p, now)
|
||
|
||
if _, err := s.bg.Exec(ctx, `
|
||
UPDATE bill.instance SET
|
||
license_key = $1, payload = $2::jsonb,
|
||
license_id = NULLIF($3,'')::uuid, issued_to = NULLIF($4,''),
|
||
state = $5, invalid_reason = NULL,
|
||
expires_at = $6, grace_until = $7,
|
||
applied_at = now(), applied_by = $8, checked_at = now(),
|
||
clock_max_seen = GREATEST(clock_max_seen, now())
|
||
WHERE id
|
||
`, key, string(body), licenseUUID(p.LicenseID), p.IssuedTo, st.State,
|
||
st.ExpiresAt, st.GraceUntil, nullUUID(userID)); err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
|
||
// Слід у bill.license_keys — не дублювання, а відповідь на питання
|
||
// «які ключі тут бували». Ключ, який замінили місяць тому, з
|
||
// bill.instance зникає безслідно, і з'ясувати, чи був він взагалі,
|
||
// стає ніяк. tenant_id лишається порожнім навмисно: ліцензія
|
||
// належить інсталяції, а не кабінету (див. 0069).
|
||
if err := s.recordLicenseKey(ctx, p, key, inst.InstallID); err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
|
||
if err := s.applyEntitlements(ctx, p, st.State); err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
return s.LicenseStatus(ctx, v)
|
||
}
|
||
|
||
// ClearLicense знімає ключ.
|
||
//
|
||
// Не «видаляє ліцензію»: рядок у bill.license_keys лишається, зникає
|
||
// лише те, що діє. Кабінети повертаються до self_hosted, тобто до
|
||
// стану «стелі не задані», а не до найменшого тарифу — зняття ключа не
|
||
// має бути дією, після якої система перестає приймати хости.
|
||
func (s *Store) ClearLicense(ctx context.Context, userID string) error {
|
||
if _, err := s.bg.Exec(ctx, `
|
||
UPDATE bill.instance SET
|
||
license_key = NULL, payload = NULL, license_id = NULL, issued_to = NULL,
|
||
state = 'unlicensed', invalid_reason = NULL,
|
||
expires_at = NULL, grace_until = NULL,
|
||
applied_at = now(), applied_by = $1, checked_at = now()
|
||
WHERE id
|
||
`, nullUUID(userID)); err != nil {
|
||
return err
|
||
}
|
||
return s.applyEntitlements(ctx, nil, LicenseUnlicensed)
|
||
}
|
||
|
||
// ЧОМУ СТАН ЛІЦЕНЗІЇ ПЕРЕРАХОВУЄТЬСЯ ТАКТОМ, А НЕ РАЗ ПРИ СТАРТІ
|
||
//
|
||
// Бо стан ліцензії міняється БЕЗ ЖОДНОЇ ДІЇ — просто від того, що минув
|
||
// час. Інсталяція, яку не перезавантажували чотири місяці (а це норма),
|
||
// перейшла б із active в expired і не помітила б цього до наступного
|
||
// перезапуску. Тобто попередження «лишилось 5 діб» людина побачила б
|
||
// через півроку після того, як вони скінчились.
|
||
//
|
||
// Такт же робить три речі: рухає монотонний годинник, перечитує підпис
|
||
// (ключ міг стати невалідним через ротацію ключів підпису у збірці) і
|
||
// переписує стелі, якщо стан змінився.
|
||
|
||
// RefreshLicense — такт перевірки в рантаймі.
|
||
func (s *Store) RefreshLicense(ctx context.Context, v *LicenseVerifier) (LicenseState, error) {
|
||
inst, err := s.readInstance(ctx)
|
||
if err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
|
||
now := time.Now().UTC()
|
||
eff, warped := effectiveNow(now, inst.ClockMaxSeen)
|
||
|
||
if inst.LicenseKey == "" {
|
||
if _, err := s.bg.Exec(ctx, `
|
||
UPDATE bill.instance SET checked_at = now(),
|
||
clock_max_seen = GREATEST(clock_max_seen, now()) WHERE id`); err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
return s.LicenseStatus(ctx, v)
|
||
}
|
||
|
||
p, perr := v.ParseLicense(inst.LicenseKey)
|
||
if perr != nil {
|
||
// Ключ був прийнятий раніше, а тепер не перевіряється. Найчастіша
|
||
// причина — збірку оновили, і ключ підпису, яким його видали,
|
||
// з неї прибрали.
|
||
//
|
||
// Стан invalid, стелі НЕ чіпаємо. Це принципово: ми не знаємо,
|
||
// чи справді ключ підроблений, чи це ми загубили відкритий ключ,
|
||
// — і в другому випадку відібрати в клієнта стелі означало б
|
||
// покарати його за нашу помилку розгортання. Видно це на
|
||
// сторінці, і полагодити має людина, а не тиха дія коду.
|
||
if _, err := s.bg.Exec(ctx, `
|
||
UPDATE bill.instance SET state = 'invalid', invalid_reason = $1,
|
||
checked_at = now(), clock_max_seen = GREATEST(clock_max_seen, now())
|
||
WHERE id`, perr.Error()); err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
return s.LicenseStatus(ctx, v)
|
||
}
|
||
|
||
st := EvaluateLicense(p, eff)
|
||
|
||
if _, err := s.bg.Exec(ctx, `
|
||
UPDATE bill.instance SET
|
||
state = $1, invalid_reason = NULL,
|
||
expires_at = $2, grace_until = $3,
|
||
checked_at = now(),
|
||
clock_max_seen = GREATEST(clock_max_seen, now()),
|
||
clock_warped_at = CASE WHEN $4 THEN now() ELSE clock_warped_at END
|
||
WHERE id
|
||
`, st.State, st.ExpiresAt, st.GraceUntil, warped); err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
|
||
// Стелі переписуються щотакту, а не лише на зміні стану.
|
||
//
|
||
// Дорожче на один UPDATE на кабінет за годину — і рівно це закриває
|
||
// найтихішу з поломок: рядок entitlements, зіпсований чужою правкою
|
||
// руками в базі, інакше лишався б чинним нескінченно, бо «стан не
|
||
// змінився».
|
||
if err := s.applyEntitlements(ctx, p, st.State); err != nil {
|
||
return LicenseState{}, err
|
||
}
|
||
return s.LicenseStatus(ctx, v)
|
||
}
|
||
|
||
// recordLicenseKey лишає слід про застосований ключ.
|
||
func (s *Store) recordLicenseKey(ctx context.Context, p *LicensePayload, key, installID string) error {
|
||
body, err := json.Marshal(p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
prefix := key
|
||
if len(prefix) > 16 {
|
||
prefix = prefix[:16]
|
||
}
|
||
// Підпис лягає окремою колонкою, хоч він і є всередині key.
|
||
//
|
||
// Не заради місця, а заради відповіді на питання, яке ставлять раз
|
||
// на кілька років і завжди в поганий день: «яким саме ключем підпису
|
||
// це підписано і чи сходиться воно досі». Дістати підпис регулярним
|
||
// виразом із текстового поля посеред розслідування — не те, чим
|
||
// хочеться займатись; колонка bytea для цього в 0009 і заведена.
|
||
// Первинний ключ рядка генерує база, а не lid із payload.
|
||
//
|
||
// Спокуса взяти lid очевидна — «нехай ідентифікатор ліцензії буде
|
||
// ідентифікатором рядка». Але lid пише той, хто випускає ключ, і
|
||
// продовження ліцензії з тим самим lid (звичайна практика) уперлось
|
||
// би в первинний ключ. Наслідок був би найгіршим із можливих:
|
||
// дійсний оплачений ключ не застосовується, а людина бачить
|
||
// «внутрішню помилку». Тотожність рядка тримає хеш самого ключа —
|
||
// саме він і є тим, що не повторюється.
|
||
_, err = s.bg.Exec(ctx, `
|
||
INSERT INTO bill.license_keys
|
||
(plan_key, key_hash, key_prefix, payload, signature, signing_key_id,
|
||
status, max_devices, max_map_nodes, features, bound_install_id, issued_to,
|
||
activated_at, expires_at)
|
||
VALUES (COALESCE((SELECT key FROM bill.plans WHERE key = $1), 'self_hosted'),
|
||
digest($2, 'sha256'), $3, $4::jsonb, $5, $6,
|
||
'active', $7, $8, $9, $10::uuid, NULLIF($11,''),
|
||
now(), $12)
|
||
ON CONFLICT (key_hash) DO UPDATE SET
|
||
status = 'active', activated_at = now(),
|
||
bound_install_id = EXCLUDED.bound_install_id
|
||
`, p.Plan, key, prefix, string(body),
|
||
licenseSignatureBytes(key), p.KeyID,
|
||
p.MaxDevices, p.MaxMapNodes, featuresOrEmpty(p.Features), installID,
|
||
p.IssuedTo, timeOrNil(p.ExpiresAt))
|
||
return err
|
||
}
|
||
|
||
// licenseSignatureBytes дістає підпис із уже перевіреного ключа.
|
||
//
|
||
// Порожній зріз замість помилки: сюди потрапляє лише ключ, який щойно
|
||
// пройшов ParseLicense, тобто розкодувався напевно. Але падати на
|
||
// збереженні СЛІДУ через несподіванку в розборі не можна — ліцензія при
|
||
// цьому вже застосована, і відкотити її частково гірше, ніж лишити
|
||
// колонку порожньою.
|
||
func licenseSignatureBytes(key string) []byte {
|
||
parts := strings.Split(strings.TrimSpace(key), ".")
|
||
if len(parts) != 3 {
|
||
return []byte{}
|
||
}
|
||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||
if err != nil {
|
||
return []byte{}
|
||
}
|
||
return sig
|
||
}
|
||
|
||
// licenseUUID пропускає далі лише те, що справді схоже на uuid.
|
||
//
|
||
// lid у payload пише той, хто випускає ключ, і поле це вільне. Рядок,
|
||
// який не є uuid, доїхав би до `::uuid` у запиті й перетворився б на
|
||
// 500 «внутрішня помилка» — тобто дійсний за підписом ключ не
|
||
// застосувався б через косметичне поле. Порожній рядок означає «нехай
|
||
// база згенерує свій», і це нормальний стан.
|
||
func licenseUUID(v string) string {
|
||
if len(v) != 36 {
|
||
return ""
|
||
}
|
||
for i, c := range v {
|
||
switch i {
|
||
case 8, 13, 18, 23:
|
||
if c != '-' {
|
||
return ""
|
||
}
|
||
default:
|
||
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||
if !isHex {
|
||
return ""
|
||
}
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
|
||
func featuresOrEmpty(f []string) []string {
|
||
if f == nil {
|
||
return []string{}
|
||
}
|
||
return f
|
||
}
|
||
|
||
// timeOrNil — вказівник, а не any з nil усередині.
|
||
//
|
||
// Порожній інтерфейс із nil доїжджає до драйвера як «значення
|
||
// невідомого типу», і колонка timestamptz на нього реагує по-різному
|
||
// залежно від версії. Типізований nil-вказівник однозначний завжди.
|
||
func timeOrNil(unix int64) *time.Time {
|
||
if unix == 0 {
|
||
return nil
|
||
}
|
||
t := time.Unix(unix, 0).UTC()
|
||
return &t
|
||
}
|
||
|
||
// applyEntitlements переписує стелі всім кабінетам, які живуть із
|
||
// ліцензії.
|
||
//
|
||
// САМЕ «ЯКІ ЖИВУТЬ ІЗ ЛІЦЕНЗІЇ» — це та межа, за яку не протікає
|
||
// платіжка. Кабінет, стелі якого прийшли з підписки (source = 'stripe')
|
||
// або поставлені руками (source = 'manual'), ключ інсталяції не чіпає.
|
||
// Без цієї умови перший же ключ, застосований на спільному хостингу,
|
||
// переписав би чужі оплачені стелі своїми.
|
||
func (s *Store) applyEntitlements(ctx context.Context, p *LicensePayload, state string) error {
|
||
planKey := "self_hosted"
|
||
if p != nil && p.Plan != "" {
|
||
planKey = p.Plan
|
||
}
|
||
|
||
base, features, err := s.planLimits(ctx, planKey)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
limits := LimitsFromPayload(base, p)
|
||
features = FeaturesFromPayload(features, p)
|
||
|
||
reason := "license"
|
||
switch state {
|
||
case LicenseUnlicensed, LicenseInvalid:
|
||
reason = "migration"
|
||
case LicenseExpired:
|
||
reason = "license_expired"
|
||
}
|
||
|
||
ids, err := s.licensedTenants(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, id := range ids {
|
||
// Стелі рахуються ОКРЕМО для кожного кабінету, бо «не нижче
|
||
// зайнятого» — це про конкретні числа конкретного кабінету, а
|
||
// не про інсталяцію загалом.
|
||
used, err := s.TenantUsage(ctx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
eff := EffectiveLimits(limits, state, used)
|
||
|
||
var validUntil, graceUntil *time.Time
|
||
if p != nil {
|
||
v, g := p.Expires(), p.GraceUntil()
|
||
validUntil, graceUntil = &v, &g
|
||
}
|
||
if err := s.InTenantTx(ctx, id, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
UPDATE bill.entitlements SET
|
||
plan_key = $2,
|
||
max_devices = $3, max_maps = $4, max_map_nodes = $5,
|
||
max_agents = $6, max_users = $7,
|
||
metric_retention_days = $8, features = $9,
|
||
source = 'license_key', reason = $10,
|
||
valid_until = $11, grace_until = $12,
|
||
updated_at = now()
|
||
WHERE tenant_id = $1 AND source = 'license_key'
|
||
`, id, planKey, eff.MaxDevices, eff.MaxMaps, eff.MaxMapNodes,
|
||
eff.MaxAgents, eff.MaxUsers, eff.RetentionDays, features,
|
||
reason, validUntil, graceUntil)
|
||
return err
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// planLimits — стелі й фічі тарифу.
|
||
func (s *Store) planLimits(ctx context.Context, key string) (PlanLimits, []string, error) {
|
||
var l PlanLimits
|
||
var f []string
|
||
err := s.bg.QueryRow(ctx, `
|
||
SELECT max_devices, max_maps, max_map_nodes, max_agents, max_users,
|
||
metric_retention_days, features
|
||
FROM bill.plans WHERE key = $1
|
||
`, key).Scan(&l.MaxDevices, &l.MaxMaps, &l.MaxMapNodes, &l.MaxAgents,
|
||
&l.MaxUsers, &l.RetentionDays, &f)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
// Ключ називає тариф, якого в цій збірці немає. Не помилка
|
||
// застосування: ключ дійсний, підпис сходиться, просто план
|
||
// новіший за інсталяцію. Стелі беремо з самого ключа, а базою
|
||
// стає «без обмежень» — бо клієнт заплатив, і відмовляти йому
|
||
// через незнайоме слово в полі plan ми не маємо права.
|
||
return PlanLimits{RetentionDays: 400}, []string{}, nil
|
||
}
|
||
if err != nil {
|
||
return l, nil, err
|
||
}
|
||
if f == nil {
|
||
f = []string{}
|
||
}
|
||
return l, f, nil
|
||
}
|
||
|
||
// licensedTenants — кабінети, стелі яких задає ліцензія інсталяції.
|
||
func (s *Store) licensedTenants(ctx context.Context) ([]string, error) {
|
||
rows, err := s.bg.Query(ctx, `
|
||
SELECT e.tenant_id::text
|
||
FROM bill.entitlements e
|
||
JOIN core.tenants t ON t.id = e.tenant_id AND t.deleted_at IS NULL
|
||
WHERE e.source = 'license_key'
|
||
`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []string
|
||
for rows.Next() {
|
||
var id string
|
||
if err := rows.Scan(&id); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, id)
|
||
}
|
||
return out, rows.Err()
|
||
}
|