П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
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 працює справжній.
517 lines
25 KiB
Go
517 lines
25 KiB
Go
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))
|
||
}
|