Netpulse_SasS/server/internal/store/billing_license.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

517 lines
25 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package store
import (
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// Ліцензійні ключі для self-hosted.
//
// ЧОМУ ЦЕ ВАЖЛИВІШЕ ЗА STRIPE
//
// Бо продукт ставлять клієнту НА ЙОГО ЗАЛІЗО, і найчастіше — у сегмент
// без виходу назовні. Це не рідкість і не окремий випадок: мережа, за
// якою треба стежити, зазвичай і є та мережа, з якої нікуди не
// достукатись. Ліцензія, яка вимагає зателефонувати додому, у такій
// інсталяції не працює взагалі — не «працює гірше», а не працює.
//
// Звідси головна вимога до формату: ключ мусить нести всю правду про
// себе САМ. Строк, стелі, набір фіч, кому виданий, до якої інсталяції
// прив'язаний — усе всередині, під підписом. Перевірка — це перевірка
// підпису над байтами, які вже лежать перед нами, і більше нічого.
//
// ФОРМАТ
//
// NP1.<base64url(payload)>.<base64url(підпис)>
//
// Три частини, крапка як роздільник — та сама форма, що в JWT, і це
// свідомо: її впізнають, її не ламають поштові клієнти, вона копіюється
// подвійним кліком. Але це НЕ JWT: там алгоритм оголошено в самому
// токені, і найвідоміша вразливість формату — саме через це («alg:
// none»). Тут алгоритм один, він у префіксі NP1 і не узгоджується ні з
// ким.
//
// Підпис Ed25519. Обґрунтування вибору проти RSA-4096 з 0009 —
// у міграції 0069; коротко: 64 байти проти 512 у ключі, який людина
// вводить руками, і жодного параметра, який можна виставити неправильно.
//
// ПІДПИСУЄМО САМЕ ТІ БАЙТИ, ЯКІ ВЕЗЕМО
//
// Підпис рахується над base64-розкодованим payload дослівно, а не над
// «канонічним виглядом структури». Це прибирає цілий клас поломок:
// будь-яка канонізація (порядок полів, пропуск порожніх, формат чисел)
// — це домовленість між тим, хто підписав, і тим, хто перевіряє, і
// розходиться вона мовчки, через рік, після нейтральної на вигляд зміни
// структури. Байти не розходяться ніколи.
//
// Наслідок, з яким треба жити: невідомі поля в payload переживають
// перевірку. Це правильно — ключ, виданий новішою збіркою генератора,
// має лишатись дійсним на старішій інсталяції, а не ставати
// «недійсним» через поле, про яке вона не знає.
// ---------------------------------------------------------------------
// Формат ключа
// ---------------------------------------------------------------------
const licensePrefix = "NP1"
var (
// ErrLicenseFormat — рядок не схожий на ключ узагалі.
ErrLicenseFormat = errors.New("це не схоже на ліцензійний ключ")
// ErrLicenseSignature — підпис не сходиться або ключ підпису невідомий.
ErrLicenseSignature = errors.New("підпис ліцензії не сходиться")
// ErrLicenseInstall — ключ виданий іншій інсталяції.
ErrLicenseInstall = errors.New("ключ виданий іншій інсталяції")
// ErrNoVerifier — на цій збірці немає жодного відкритого ключа
// підпису, тобто перевірити ліцензію нічим.
ErrNoVerifier = errors.New("відкритий ключ підпису ліцензій не налаштовано")
)
// LicensePayload — те, що лежить під підписом.
//
// Стелі — вказівники, і саме тому. Відсутнє поле означає «без
// обмеження», нуль — «нуль штук». Для ліцензії ця різниця вирішальна:
// ключ, у якому забули max_devices, має відкривати необмежену
// інсталяцію, а не блокувати перший же хост. Помилка в цей бік
// коштувала б клієнту робочого дня, а нам — звернення о шостій ранку.
type LicensePayload struct {
V int `json:"v"`
LicenseID string `json:"lid"`
KeyID string `json:"kid"`
// InstallID порожній — ключ не прив'язаний до інсталяції.
//
// Так виглядає ключ, виданий ДО розгортання: install_id заводить
// сама інсталяція при першому старті, і на момент продажу його ще
// не існує. Прив'язка відбувається при застосуванні (bound_install_id
// у bill.license_keys) — тобто перший, хто ввів ключ, і забирає його
// собі.
InstallID string `json:"install,omitempty"`
Plan string `json:"plan"`
IssuedTo string `json:"to,omitempty"`
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
// GraceDays — скільки діб після строку все лишається як було.
//
// Не «скільки терпіти неплатника»: пільговий період існує проти
// НАШОЇ ж помилки — лист із рахунком у спамі, відпустка людини, яка
// підписує, оплата, що йшла три дні. Нуль тут означав би, що
// продукт починає обмежувати клієнта в день, коли той іще навіть не
// знає про проблему.
GraceDays int `json:"grace,omitempty"`
MaxDevices *int `json:"max_devices,omitempty"`
MaxMaps *int `json:"max_maps,omitempty"`
MaxMapNodes *int `json:"max_map_nodes,omitempty"`
MaxAgents *int `json:"max_agents,omitempty"`
MaxUsers *int `json:"max_users,omitempty"`
RetentionDays *int `json:"retention_days,omitempty"`
Features []string `json:"features,omitempty"`
}
// Expires — строк дії як час.
func (p *LicensePayload) Expires() time.Time { return time.Unix(p.ExpiresAt, 0).UTC() }
// GraceUntil — до якої миті все лишається як було.
func (p *LicensePayload) GraceUntil() time.Time {
return p.Expires().AddDate(0, 0, p.GraceDays)
}
// ---------------------------------------------------------------------
// Перевірка підпису
// ---------------------------------------------------------------------
// LicenseVerifier — відкриті ключі, якими перевіряють ліцензії.
//
// Кілька ключів одночасно, як у crypto.Keyring, і з тієї ж причини:
// ключ підпису колись доведеться змінити, а вже видані ліцензії мають
// лишитись дійсними до кінця свого строку. Тому payload несе kid, а не
// покладається на «ключ у нас один».
type LicenseVerifier struct {
keys map[string]ed25519.PublicKey
}
// ParseLicenseVerifier читає ключі з рядка `kid=<hex|base64>[,kid2=…]`.
//
// Той самий синтаксис, що й у -dek: два різні формати для двох наборів
// ключів в одному продукті — це два способи помилитись.
//
// Порожній рядок дає порожній набір, а не помилку. Це робочий стан:
// збірка без вшитого ключа підпису — це збірка для розробки й для
// клієнта, який ліцензію не купував. Вона працює; просто ключ у ній
// перевірити нічим, і будь-який введений ключ отримає стан invalid із
// причиною, названою вголос.
func ParseLicenseVerifier(spec string) (*LicenseVerifier, error) {
v := &LicenseVerifier{keys: map[string]ed25519.PublicKey{}}
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
kid, raw, ok := strings.Cut(part, "=")
if !ok {
return nil, fmt.Errorf("очікується kid=ключ, отримано %q", part)
}
key, err := decodeKeyBytes(raw)
if err != nil {
return nil, fmt.Errorf("ключ %s: %w", kid, err)
}
if len(key) != ed25519.PublicKeySize {
return nil, fmt.Errorf("ключ %s: очікується %d байтів, отримано %d",
kid, ed25519.PublicKeySize, len(key))
}
v.keys[strings.TrimSpace(kid)] = ed25519.PublicKey(key)
}
return v, nil
}
// NewLicenseVerifier — набір із готових ключів (для тестів і генератора).
func NewLicenseVerifier(keys map[string]ed25519.PublicKey) *LicenseVerifier {
cp := make(map[string]ed25519.PublicKey, len(keys))
for k, v := range keys {
cp[k] = v
}
return &LicenseVerifier{keys: cp}
}
// Empty — чи є чим перевіряти.
func (v *LicenseVerifier) Empty() bool { return v == nil || len(v.keys) == 0 }
func decodeKeyBytes(raw string) ([]byte, error) {
if b, err := hex.DecodeString(raw); err == nil {
return b, nil
}
if b, err := base64.StdEncoding.DecodeString(raw); err == nil {
return b, nil
}
if b, err := base64.RawURLEncoding.DecodeString(raw); err == nil {
return b, nil
}
return nil, errors.New("не hex і не base64")
}
// ParseLicense розбирає ключ і перевіряє підпис.
//
// Порядок кроків не косметичний: спершу форма, потім підпис, і лише
// потім будь-що про зміст. Розбирати payload до перевірки підпису
// означало б довіряти числам, які написав той, кого вони обмежують.
func (v *LicenseVerifier) ParseLicense(key string) (*LicensePayload, error) {
key = strings.TrimSpace(key)
// Ключ їздить у листах і в чатах, звідки повертається з переносами
// рядків і пробілами всередині. Прибрати їх тут дешевше, ніж
// пояснювати людині, що саме в її ключі не так.
key = strings.NewReplacer("\n", "", "\r", "", " ", "", "\t", "").Replace(key)
parts := strings.Split(key, ".")
if len(parts) != 3 || parts[0] != licensePrefix {
return nil, ErrLicenseFormat
}
body, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, ErrLicenseFormat
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, ErrLicenseFormat
}
if len(sig) != ed25519.SignatureSize {
return nil, ErrLicenseFormat
}
// kid лежить у payload, тобто читати його доводиться ДО перевірки
// підпису — інакше невідомо, чим перевіряти. Це не дірка: підроблений
// kid не дає нічого, бо вказує лише на те, ЯКИМ відкритим ключем
// пробувати, а ключі всі наші. Найгірше, що дає брехня тут, —
// «невідомий ключ підпису».
var head LicensePayload
if err := json.Unmarshal(body, &head); err != nil {
return nil, ErrLicenseFormat
}
if v.Empty() {
return nil, ErrNoVerifier
}
pub, ok := v.keys[head.KeyID]
if !ok {
return nil, fmt.Errorf("%w: невідомий ключ підпису %q", ErrLicenseSignature, head.KeyID)
}
if !ed25519.Verify(pub, body, sig) {
return nil, ErrLicenseSignature
}
// Версія перевіряється ПІСЛЯ підпису: чужому числу до перевірки віри
// немає, а після — це вже наше власне число.
if head.V != 1 {
return nil, fmt.Errorf("%w: версія ключа %d не підтримується цією збіркою",
ErrLicenseFormat, head.V)
}
return &head, nil
}
// SignLicense збирає ключ. Живе поруч із перевіркою навмисно.
//
// Генератор ліцензій — окремий інструмент і в цьому бінарнику не
// потрібен. Але формат, у якому підписують, і формат, у якому
// перевіряють, — це одна річ, і рознесені по різних репозиторіях вони
// розійдуться. Тут же на цій функції стоять тести: перевірити, що
// підпис сходиться, інакше нічим.
func SignLicense(priv ed25519.PrivateKey, p LicensePayload) (string, error) {
if p.V == 0 {
p.V = 1
}
body, err := json.Marshal(p)
if err != nil {
return "", err
}
sig := ed25519.Sign(priv, body)
return licensePrefix + "." +
base64.RawURLEncoding.EncodeToString(body) + "." +
base64.RawURLEncoding.EncodeToString(sig), nil
}
// ---------------------------------------------------------------------
// Стан ліцензії
// ---------------------------------------------------------------------
// Стани. Рядки збігаються з CHECK у bill.instance.
const (
LicenseUnlicensed = "unlicensed"
LicenseActive = "active"
LicenseGrace = "grace"
LicenseExpired = "expired"
LicenseInvalid = "invalid"
)
// LicenseState — усе, що інтерфейс і такт знають про ліцензію.
type LicenseState struct {
State string `json:"state"`
Reason string `json:"reason,omitempty"`
InstallID string `json:"install_id"`
LicenseID string `json:"license_id,omitempty"`
IssuedTo string `json:"issued_to,omitempty"`
Plan string `json:"plan,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
GraceUntil *time.Time `json:"grace_until,omitempty"`
// DaysLeft — скільки діб лишилось до кінця пільгового періоду.
// Від'ємне після нього; для unlicensed не заповнюється.
DaysLeft *int `json:"days_left,omitempty"`
// ClockWarped — системний час пішов назад проти побаченого раніше.
//
// Показується, але нічого не вимикає: годинник з'їжджає й сам.
ClockWarped bool `json:"clock_warped,omitempty"`
ClockMaxSeen *time.Time `json:"clock_max_seen,omitempty"`
CheckedAt *time.Time `json:"checked_at,omitempty"`
AppliedAt *time.Time `json:"applied_at,omitempty"`
VerifierEmpty bool `json:"verifier_empty,omitempty"`
}
// EvaluateLicense — стан ключа на заданий момент.
//
// Чиста функція: жодної бази, жодного time.Now(). Саме тому її можна
// перевірити тестом на всіх межах, включно з тими, які в житті настають
// раз на рік і на які ніхто не дивиться.
//
// now сюди подають НЕ як time.Now(), а як max(time.Now(), останній
// побачений час) — див. RefreshLicense. Годинник належить тому, кого
// ліцензія обмежує, і рахувати строк за ним просто так не можна.
func EvaluateLicense(p *LicensePayload, now time.Time) LicenseState {
if p == nil {
return LicenseState{State: LicenseUnlicensed}
}
st := LicenseState{
State: LicenseActive,
LicenseID: p.LicenseID,
IssuedTo: p.IssuedTo,
Plan: p.Plan,
}
exp, grace := p.Expires(), p.GraceUntil()
st.ExpiresAt, st.GraceUntil = &exp, &grace
// Округлення вгору навмисно: «лишилось 0 діб» людина читає як «уже
// все», хоча насправді ще є частина доби. Показувати 1 доти, доки
// строк справді не минув, чесніше за арифметично точний нуль.
days := int(grace.Sub(now).Hours() / 24)
if grace.After(now) {
days++
}
st.DaysLeft = &days
switch {
case now.Before(exp):
st.State = LicenseActive
case now.Before(grace):
st.State = LicenseGrace
st.Reason = "строк дії минув, триває пільговий період"
default:
st.State = LicenseExpired
st.Reason = "строк дії та пільговий період минули"
}
return st
}
// ---------------------------------------------------------------------
// Стелі, які дає ліцензія
// ---------------------------------------------------------------------
// PlanLimits — набір стель. Вказівник = «без обмеження».
type PlanLimits struct {
MaxDevices *int `json:"max_devices"`
MaxMaps *int `json:"max_maps"`
MaxMapNodes *int `json:"max_map_nodes"`
MaxAgents *int `json:"max_agents"`
MaxUsers *int `json:"max_users"`
RetentionDays int `json:"metric_retention_days"`
}
// Usage — скільки слотів зайнято. Дзеркало bill.usage_now.
type Usage struct {
Devices int `json:"devices"`
Maps int `json:"maps"`
Agents int `json:"agents"`
Users int `json:"users"`
}
// ЩО РОБИТЬ ПРОСТРОЧЕНА ЛІЦЕНЗІЯ І ЧОМУ САМЕ ЦЕ
//
// Не вимикає нічого. Ні збору, ні алертів, ні сповіщень, ні доступу до
// зібраного. Замерзає рівно одне — РІСТ: не з'являється новий хост,
// зонд, користувач, мапа.
//
// Це не поблажливість, це єдина відповідь, яка витримує перевірку
// наслідками. Моніторинг, що перестав моніторити через несплачений
// рахунок, — аварія в мережі клієнта, спричинена нами. Він не побачить
// падіння магістралі й дізнається про нього від абонентів; ми при цьому
// грошей не отримаємо, а отримаємо звернення й репутацію продукту, який
// тихо перестав працювати саме тоді, коли був потрібен. Продавати
// прилад, який сам себе вимикає, можна рівно один раз.
//
// Друга частина того самого рішення, і без неї перша нічого не варта:
// стеля ніколи не опускається нижче ФАКТИЧНО зайнятого. Інсталяція з
// п'ятьмастами хостами на протермінованому ключі лишається
// п'ятисотхостовою, а не падає до п'ятнадцяти. Інакше «нічого не
// вимикаємо» було б брехнею: стеля нижча за використане означає, що
// перший же перезапис entitlements зробив би 485 хостів такими, які не
// можна повернути з архіву, а тригер на UPDATE — тими, які не можна
// ввімкнути назад.
//
// Так само поводиться стан unlicensed: інсталяція, яку щойно розгорнули
// й ще не купили, має показувати продукт, а не стіну.
// EffectiveLimits — стелі, які насправді лягають у bill.entitlements.
func EffectiveLimits(plan PlanLimits, state string, used Usage) PlanLimits {
if state != LicenseExpired {
return plan
}
out := plan
out.MaxDevices = notBelow(plan.MaxDevices, used.Devices)
out.MaxMaps = notBelow(plan.MaxMaps, used.Maps)
out.MaxAgents = notBelow(plan.MaxAgents, used.Agents)
out.MaxUsers = notBelow(plan.MaxUsers, used.Users)
// MaxMapNodes свідомо лишається як є: він рахується НА МАПУ, а не
// на кабінет, і «скільки вузлів на найбільшій мапі» — не те число,
// яке тут відоме. Наслідок м'який і у правильний бік: наявні вузли
// не зникають (тригер стоїть на вставці), а домалювати новий на вже
// переповненій мапі не вийде — що після прострочення й правильно.
return out
}
// notBelow не дає стелі опуститись нижче вже зайнятого.
//
// nil (без обмеження) лишається nil: «без стелі» вже не нижче за будь-що.
func notBelow(limit *int, used int) *int {
if limit == nil {
return nil
}
if *limit >= used {
return limit
}
v := used
return &v
}
// LimitsFromPayload — стелі, записані в самому ключі.
//
// Ключ перекриває тариф, а не навпаки, і це не дрібниця: домовленість
// («вам 250 хостів, хоча в тарифі 100») живе саме тут, у підписаному
// рядку, а не в чиємусь листі. Поле, якого в ключі немає, лишається
// таким, яким його задає тариф.
func LimitsFromPayload(base PlanLimits, p *LicensePayload) PlanLimits {
if p == nil {
return base
}
out := base
if p.MaxDevices != nil {
out.MaxDevices = p.MaxDevices
}
if p.MaxMaps != nil {
out.MaxMaps = p.MaxMaps
}
if p.MaxMapNodes != nil {
out.MaxMapNodes = p.MaxMapNodes
}
if p.MaxAgents != nil {
out.MaxAgents = p.MaxAgents
}
if p.MaxUsers != nil {
out.MaxUsers = p.MaxUsers
}
if p.RetentionDays != nil {
out.RetentionDays = *p.RetentionDays
}
return out
}
// FeaturesFromPayload — набір можливостей.
//
// Порожній перелік у ключі означає «як у тарифі», а не «жодної».
// Різниця та сама, що між nil і нулем у стелях, і ціна помилки та сама:
// ключ без переліку фіч закрив би клієнтові все, за що він заплатив.
func FeaturesFromPayload(base []string, p *LicensePayload) []string {
if p == nil || len(p.Features) == 0 {
return base
}
out := append([]string(nil), p.Features...)
sort.Strings(out)
return out
}
// ---------------------------------------------------------------------
// Монотонний годинник
// ---------------------------------------------------------------------
// clockSkewTolerance — наскільки час має піти назад, щоб це вважалось
// зсувом, а не звичайним життям.
//
// Година, а не хвилина. Дрібні стрибки назад — норма: поправка NTP
// після довгого простою, віртуалка, яку зняли з паузи, перехід на
// зимовий час у системі, де хтось тримає локальний час замість UTC.
// Позначати їх зсувом означало б показувати попередження щотижня, а
// попередження, яке блимає щотижня, перестають читати.
const clockSkewTolerance = time.Hour
// effectiveNow — час, за яким рахують строк ліцензії.
//
// Максимум із поточного й найпізнішого побаченого. Годинник назад не
// йде; якщо now менший за seen, його рухали — і рахувати строк за ним
// означало б віддати керування строком тому, кого строк обмежує.
//
// Це не криптографія й не заважає рішучому обходу: досить стерти рядок
// у базі. Воно ловить дешеву спробу, коштує одного UPDATE на годину й
// нічого не ламає, коли годинник з'їхав чесно.
func effectiveNow(now, seen time.Time) (time.Time, bool) {
if seen.IsZero() || !now.Before(seen) {
return now, false
}
return seen, now.Before(seen.Add(-clockSkewTolerance))
}