Авторизация в Go без боли: как Casbin заменяет километры if-проверок
- среда, 20 мая 2026 г. в 00:00:14

Пока в приложении две роли и три проверки, авторизация умещается в if user.Role == "admin". Но стоит добавить пару ресурсов, ролей и исключений — и условные проверки начинают расползаться по хендлерам, дублироваться и жить своей жизнью. В этой статье разберём, как навести порядок с помощью Casbin: вынесем правила доступа из кода в конфиг, пройдём путь от простого ACL до RBAC с иерархией ролей, соберём HTTP-сервер на Go с авторизационной middleware и обсудим грабли, на которые легко наступить по дороге.
Casbin — библиотека для управления доступом, которая переносит большую часть авторизационной логики из кода в конфиг. Вы описываете, кому и что разрешено, через модели и политики, а Casbin на каждый запрос выносит вердикт: разрешить или запретить.
Casbin стоит рассмотреть, если:
ролей и правил становится достаточно много, чтобы ручные if-проверки превратились в запутанную лапшу;
нужна единая точка принятия решений по доступу, а не проверки, разбросанные по хендлерам и сервисам;
нужна модель авторизации, которую можно переиспользовать в других сервисах;
правила доступа должны меняться без перекомпиляции — например, администратор редактирует права через панель управления, и они применяются на лету.
Casbin из коробки поддерживает несколько моделей авторизации:
ACL (Access Control List) — доступ задается явно для каждой пары “пользователь — ресурс”. Подходит для небольших систем, где нужно точечно раздать права.
RBAC (Role-Based Access Control) — пользователи получают роли, а права привязаны к ролям.
ABAC (Attribute-Based Access Control) — доступ зависит от атрибутов: времени суток, IP-адреса, типа устройства и любых других параметров.
Помимо перечисленных моделей, Casbin поддерживает ряд других — их полный список приведен в документации. Библиотека позволяет комбинировать эти модели и политики и адаптировать их для конкретного проекта.
Чтобы установить библиотеку Casbin, выполните:
go get github.com/casbin/casbin/v2
Это основная библиотека. Она уже умеет работать с файловыми политиками, так что для старта ничего больше не нужно.
Прежде чем писать код, разберёмся, как Casbin принимает решение «разрешить или запретить». Вся логика сводится к четырём элементам, которые описываются в файле модели.
Когда приложение запрашивает у Casbin «можно ли Алисе читать посты?», происходит следующее:
Запрос (request) — приложение формирует тройку: кто (sub), к чему (obj), что хочет сделать (act).
Сопоставление (matcher) — Casbin перебирает все правила из политики и проверяет, совпадает ли запрос хотя бы с одним из них.
Эффект (effect) — определяет итоговый вердикт: если нашлось совпадение — доступ разрешён, если нет — запрещён.
Весь этот процесс описывается в файле модели, а конкретные правила — в файле политики. Код приложения только задаёт вопрос и получает ответ true или false.
Начнём с самой простой модели: ACL (Access Control List), где мы напрямую указываем, какие действия разрешены конкретному пользователю. Файл config/model.conf:
[request_definition] r = sub, obj, act [policy_definition] p = sub, obj, act [matchers] m = r.sub == p.sub && r.obj == p.obj && r.act == p.act [policy_effect] e = some(where (p.eft == allow))
Модель состоит из четырёх секций. Разберём каждую.
[request_definition] — формат запроса
Описывает, в каком виде приложение будет формулировать вопрос к Casbin:
r — имя запроса (используется в матчере)
sub (subject) — кто запрашивает доступ, например "alice"
obj (object) — к чему, например "/posts"
act (action) — что хочет сделать, например "read"
Когда в коде вы пишете e.Enforce("alice", "/posts", "read"), Casbin превращает это в r.sub = "alice", r.obj = "/posts", r.act = "read". Порядок аргументов соответствует порядку полей в определении.
[policy_definition] — формат правил
Описывает, как устроены строки в файле политики. Буква p — имя политики. Когда в policy.csv написано:
p, alice, /posts, read
Casbin читает это как p.sub = "alice", p.obj = "/posts", p.act = "read". В нашей ACL-модели формат правил совпадает с форматом запроса, но так бывает не всегда — в RBAC-моделях политика может содержать дополнительные поля, например, роль.
[matchers] — логика сопоставления
Матчер — это логическое выражение, которое сравнивает запрос с каждым правилом из политики:
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
Casbin перебирает все строки политики и проверяет это условие для каждой. Если пришёл запрос Enforce("alice", "/posts", "read"), матчер найдёт строку p, alice, /posts, read и зафиксирует совпадение.
Именно в матчере живёт вся гибкость Casbin. Сюда можно добавить проверку ролей (g(r.sub, p.sub)), регулярные выражения для путей (keyMatch(r.obj, p.obj)) или условия на атрибуты (r.sub.Age >= 18).
[policy_effect] — итоговое решение
Определяет, что делать после того, как матчер отработал по всем правилам:
e = some(where (p.eft == allow))
p.eft (policy effect) — это скрытое поле, которое есть у каждого правила в политике, даже если вы его не объявляли в policy_definition. По умолчанию оно равно allow. То есть когда вы пишете в policy.csv: p, alice, /posts, read
Casbin внутренне читает это как p.sub = "alice", p.obj = "/posts", p.act = "read", p.eft = "allow". Но p.eft можно задать и явно, добавив четвёртое значение:
p, alice, /posts, delete, deny
Теперь у этого правила p.eft = "deny" — оно запрещает, а не разрешает.
where (p.eft == allow) — это условие-фильтр. Из всех правил, которые совпали с запросом по матчеру, where отбирает только те, у которых эффект равен allow. Правила с deny на этом этапе отбрасываются.
some(...) — квантор «существует хотя бы одно». Проверяет, осталось ли после фильтрации хотя бы одно правило. Если да — итоговый результат true (доступ разрешён). Если ни одного — false (доступ запрещен).
Всю конструкцию e = some(where (p.eft == allow)) можно прочитать так: «доступ разрешён, если среди всех сработавших правил есть хотя бы одно с эффектом allow».
Важный нюанс этой модели: если для одного и того же запроса в политике есть и allow, и deny, доступ всё равно будет разрешен — потому что some нашёл хотя бы одно разрешающее правило. Запрещающее правило просто игнорируется.
Также Casbin поддерживает несколько встроенных комбинаций. Например, !some(where (p.eft == deny)) — логика инвертирована. Здесь where фильтрует правила с эффектом deny, some проверяет, есть ли среди них хотя бы одно, а ! (отрицание) переворачивает результат. Читается так: «доступ разрешён, если нет ни одного правила с эффектом deny». Эта модель подходит, когда нужно совмещать разрешающие и запрещающие правила, причем запрет имеет приоритет. Например:
p, alice, /posts, read, allow p, alice, /posts, read, deny
С эффектом some(where (p.eft == allow)) — доступ разрешён (нашлось allow-правило). С эффектом !some(where (p.eft == deny)) — доступ запрещен (нашлось deny-правило).
Для простой ACL-модели, где все правила только разрешающие, достаточно первого варианта — some(where (p.eft == allow)).
Файл policy.csv — это таблица разрешений. Для нашей ACL-модели:
p, alice, /posts, read p, alice, /posts, write p, bob, /posts, read
Здесь alice может читать и писать посты, а bob — только читать. Префикс p указывает, что строка относится к определению политики (тому самому p из policy_definition).
ACL — самая прямолинейная модель: для каждого пользователя явно указано, что ему можно.
Модель (config/model.conf):
[request_definition] r = sub, obj, act [policy_definition] p = sub, obj, act [policy_effect] e = some(where (p.eft == allow)) [matchers] m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
Политика (config/policy.csv):
p, alice, /posts, read p, alice, /posts, write p, bob, /posts, read
Alice может читать и писать в /posts. Bob — только читать. Всё остальное запрещено по умолчанию.
Код:
package main import ( "fmt" "log" "github.com/casbin/casbin/v2" ) func main() { e, err := casbin.NewEnforcer("config/model.conf", "config/policy.csv") if err != nil { log.Fatalf("ошибка при загрузке конфига: %v", err) } simpleCheck(e, "alice", "/posts", "read") simpleCheck(e, "alice", "/posts", "write") simpleCheck(e, "bob", "/posts", "read") simpleCheck(e, "bob", "/posts", "write") } func simpleCheck(e *casbin.Enforcer, sub, obj, act string) { ok, err := e.Enforce(sub, obj, act) if err != nil { log.Printf("внутренняя ошибка: %v", err) return } if ok { fmt.Printf("%s разрешено %s %s\n", sub, act, obj) } else { fmt.Printf("%s запрещено %s %s\n", sub, act, obj) } }
NewEnforcer принимает два файла — модель и политику — и создаёт объект, который умеет отвечать на один вопрос: «можно или нельзя?». Вызов e.Enforce("alice", "/posts", "write") берёт эти три аргумента (субъект, объект, действие), прогоняет их через матчер из модели, ищет совпадение среди правил политики и возвращает true или false. Если совпавшее правило нашлось — доступ разрешён, если нет — запрещён. Весь Casbin сводится к этому: загрузил правила, вызвал Enforce, получил булево значение. А всё остальное — роли, адаптеры, сложные матчеры — это способы усложнить правила, не усложняя точку проверки.
Запускаем:
go run main.go
Получаем такой вывод:
alice разрешено read /posts alice разрешено write /posts bob разрешено read /posts bob запрещено write /posts
Мы собрали минимальный рабочий пример: настроили модель, описали права в policy.csv, прогнали через Enforce четыре запроса и получили ожидаемый вывод. Этого уже достаточно, чтобы прикрутить Casbin к небольшому сервису с фиксированным набором пользователей. Дальше будем смотреть, как та же связка model + policy + Enforce масштабируется на более сложные модели.
ACL прозрачен и удобен, пока пользователей немного. Но с ростом системы правила начинают копироваться, а любое изменение прав превращается в массовую правку policy.csv. Понятий "группа" или "роль" в модели нет. Решает эту проблему модель RBAC, которая добавляет между пользователем и правами промежуточный слой ролей.
Разберем модель на более реальном примере: соберём простой HTTP-сервер и вынесем проверку доступа в middleware. Начнём с модели — файл config/model.conf — это RBAC-модель, которую мы разбирали в предыдущем разделе:
[request_definition] r = sub, obj, act [policy_definition] p = sub, obj, act [role_definition] g = _, _ [policy_effect] e = some(where (p.eft == allow)) [matchers] m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
Функция g(r.sub, p.sub) в матчере означает, что Casbin будет проверять не имя пользователя напрямую, а его принадлежность к роли. Это позволяет назначать права ролям, а пользователям — роли.
Теперь политика. Файл config/policy.csv описывает, какие роли имеют доступ к каким ресурсам:
p, admin, /admin, read p, admin, /admin, write p, admin, /dashboard, read p, editor, /dashboard, read p, editor, /dashboard, write p, viewer, /dashboard, read g, alice, admin g, bob, editor g, charlie, viewer
Здесь видна иерархия доступа. admin может всё: читать и писать в /admin и читать /dashboard. editor может читать и писать в /dashboard, но не имеет доступа к /admin. viewer может только читать /dashboard. В конце — назначение ролей: alice — администратор, bob — редактор, charlie — читатель. Ниже представлен рисунок, как выглядит схема в редакторе Casbin:

В реальном приложении проверка доступа не вызывается вручную в каждом обработчике — для этого используется middleware. Создадим файл middleware.go:
package main import ( "github.com/casbin/casbin/v2" "net/http" ) func AuthorizationMiddleware(enforcer *casbin.Enforcer) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // В реальном проекте имя пользователя берётся из токена/сессии. // Здесь для простоты — из заголовка. user := r.Header.Get("X-User") if user == "" { http.Error(w, "пользователь не указан", http.StatusUnauthorized) return } act := methodToAction(r.Method) ok, err := enforcer.Enforce(user, r.URL.Path, act) if err != nil { http.Error(w, "ошибка проверки доступа", http.StatusInternalServerError) return } if !ok { http.Error(w, "доступ запрещён", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } } func methodToAction(method string) string { switch method { case http.MethodGet: return "read" case http.MethodPost, http.MethodPut, http.MethodPatch: return "write" case http.MethodDelete: return "delete" default: return "read" } }
Разберём, что здесь происходит. AuthorizationMiddleware принимает Enforcer и возвращает функцию, которую можно навесить на любой http.Handler. Для каждого входящего запроса middleware извлекает имя пользователя из заголовка X-User, преобразует HTTP-метод в действие (GET → read, POST/PUT/PATCH → write, DELETE → delete) и передает (user, path, action) в Enforce().
Если Enforce() вернул true — запрос проходит дальше к обработчику. Если false — клиент получает 403 Forbidden. Если произошла внутренняя ошибка — 500. Обратите внимание: отсутствие заголовка X-User обрабатывается отдельно как 401 Unauthorized — это аутентификация, не авторизация.
В продакшене вместо заголовка X-User вы будете извлекать пользователя из JWT-токена или сессии, но сама структура middleware останется такой же.
main.go — точка входа, где всё соединяется:
package main import ( "fmt" "log" "net/http" "github.com/casbin/casbin/v2" ) func main() { enforcer, err := casbin.NewEnforcer("config/model.conf", "config/policy.csv") if err != nil { log.Fatalf("не удалось создать enforcer: %v", err) } mux := http.NewServeMux() mux.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) { user := r.Header.Get("X-User") fmt.Fprintf(w, "Добро пожаловать на дашборд, %s!", user) }) mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) { user := r.Header.Get("X-User") fmt.Fprintf(w, "Админ-панель. Привет, %s!", user) }) // Оборачиваем весь mux в middleware handler := AuthorizationMiddleware(enforcer)(mux) log.Println("Сервер запущен на :8080") log.Fatal(http.ListenAndServe(":8080", handler)) }
Код компактный, и авторизация полностью отделена от бизнес-логики. Обработчики /dashboard и /admin ничего не знают о правах доступа — за это отвечает middleware. Enforcer создается один раз при старте и переиспользуется для всех запросов.
Ключевая строка — AuthorizationMiddleware(enforcer)(mux). Она оборачивает весь маршрутизатор, так что каждый запрос к любому маршруту проходит через проверку Casbin. Если нужно защитить только определенные маршруты, можно оборачивать отдельные обработчики вместо всего mux.
Запускаем сервер и тестируем с помощью curl:
go run main.go middleware.go
Затем в другом терминале:
# alice (admin) — доступ к /dashboard curl -H "X-User: alice" http://localhost:8080/dashboard # Добро пожаловать на дашборд, alice! # alice (admin) — доступ к /admin curl -H "X-User: alice" http://localhost:8080/admin # Админ-панель. Привет, alice! # bob (editor) — доступ к /dashboard curl -H "X-User: bob" http://localhost:8080/dashboard # Добро пожаловать на дашборд, bob! # bob (editor) — доступ к /admin curl -H "X-User: bob" http://localhost:8080/admin # доступ запрещён # charlie (viewer) — чтение /dashboard curl -H "X-User: charlie" http://localhost:8080/dashboard # Добро пожаловать на дашборд, charlie! # charlie (viewer) — запись в /dashboard curl -X POST -H "X-User: charlie" http://localhost:8080/dashboard # доступ запрещён # Неизвестный пользователь — curl -H "X-User: dave" http://localhost:8080/dashboard # доступ запрещён # Без заголовка X-User — 401 curl http://localhost:8080/dashboard # пользователь не указан
В Go-коде нет ни одного if user == "alice" или if role == "admin". Вся логика авторизации живёт в model.conf и policy.csv. Чтобы дать charlie права на запись в /dashboard, достаточно добавить строку p, viewer, /dashboard, write в policy.csv и перезапустить сервер — код не изменится.
Casbin — хороший инструмент, но у него есть особенность, на которую легко наткнуться при отладке. Сами вызовы вроде NewEnforcer, Enforce или LoadPolicy возвращают error и о настоящих сбоях честно сообщают. Но когда Enforce отрабатывает без ошибки и возвращает false, понять, почему именно отказ — какое правило не сматчилось, какая роль не подтянулась — по умолчанию сложно: библиотека просто говорит «нельзя». В этом разделе разберём типичные ситуации, из-за которых Enforce возвращает неожиданный false, и инструменты, которые помогают быстро локализовать причину (EnableLog, EnforceEx, кастомный логгер).
Casbin не валидирует содержимое политик. Если вы напишете aice вместо alice, ошибки не будет — просто ни одно правило не сработает для настоящей alice, и Enforce молча вернёт false. Найти такую опечатку помогает EnforceEx — он возвращает не только вердикт, но и сработавшее правило (или его отсутствие). А когда опечатка найдена и исправлена в файле, перезапускать сервис не нужно — e.LoadPolicy() перечитает политику из адаптера в рантайме.
# Опечатка — Casbin не предупредит p, aice, /posts, read # Правильно p, alice, /posts, read
То же самое с действиями и ресурсами. Написали raed вместо read? Enforce("alice", "/posts", "read") вернёт false, потому что в политике нет строки с действием read для alice — есть только raed. Никакого сообщения об ошибке вы не получите.
Ещё одна частая ошибка — лишние пробелы в policy.csv. Casbin обрезает пробелы до значения (между запятой и началом слова) — неважно, один там пробел, два или таб. А вот пробелы после значения (между концом слова и следующей запятой) сохраняются как часть этого значения.
p, alice, /posts, read # ↑ этот пробел Casbin отбросит — будет "alice" p, alice ,/posts, read # ↑ а этот сохранится — будет "alice "
Во втором случае subject сохранится как "alice " (с пробелом в конце), а Enforce("alice", ...) передаёт "alice" без пробела. Строки не совпадут, доступ будет запрещён — причём без какой-либо ошибки в логах. Особенно легко на это нарваться, когда политики редактируются в текстовом редакторе с автоформатированием или копируются из таблицы. Если сомневаетесь в этом, то можете проверить, запустив тут небольшой тест.
Три «безопасных» кейса возвращают true, а кейс с пробелом перед запятой — false, потому что subject сохраняется как "alice" с trailing-пробелом и не матчится с "alice".
Аргументы Enforce() должны точно соответствовать порядку полей в request_definition. Если в модели написано r = sub, obj, act, то вызов должен быть Enforce(sub, obj, act). Перепутали местами — получите отказ в доступе без какой-либо ошибки:
// Модель: r = sub, obj, act // Перепутан порядок — obj и act местами ok, _ := e.Enforce("alice", "read", "/posts") // Casbin интерпретирует: sub="alice", obj="read", act="/posts" // В политике нет строки (alice, read, /posts) — результат false // Правильный порядок ok, _ := e.Enforce("alice", "/posts", "read")
Компилятор Go здесь не поможет: все аргументы — строки, типы совпадают. Ошибку можно поймать только тестами или внимательным чтением модели.
Синтаксис матчера не проверяется при создании Enforcer — ошибка всплывет только при вызове Enforce(). Причём это будет ошибка в возвращаемом err, а не паника, так что если вы игнорируете ошибку — поведение станет непредсказуемым:
# Опечатка в матчере — "p.sbu" вместо "p.sub" [matchers] m = r.sub == p.sbu && r.obj == p.obj && r.act == p.act
ok, err := e.Enforce("alice", "/posts", "read") if err != nil { // Здесь будет ошибка вроде: "p.sbu is undefined" // Если err не проверять, ok будет false — и вы решите, // что правила просто нет, хотя проблема в модели log.Printf("ошибка enforce: %v", err) }
Поэтому всегда проверяйте err от Enforce(). Ошибка матчера — это не проблемы с правами, а, скорее всего, сломанная модель. Отвечать на такие ошибки нужно кодом 500, а не 403.
В этой главе — несколько практических советов, которые помогают при работе с Casbin в реальных проектах: как отлаживать решения Enforce, где хранить политики и как покрывать их тестами.
Как мы видели в разделе про отладку, понять, почему Enforce вернул false (или внезапно true), бывает непросто: метод возвращает только булево значение, без контекста. EnforceEx решает эту проблему — он дополнительно показывает, какое именно правило сработало:
ok, reason, err := e.EnforceEx("alice", "/posts", "read") if err != nil { log.Printf("ошибка: %v", err) return } if ok { fmt.Printf("доступ разрешён, сработавшее правило: %v\n", reason) // Вывод: доступ разрешён, сработавшее правило: [alice /posts read] } else { fmt.Println("доступ запрещён — ни одно правило не совпало") }
reason — слайс строк с полями сработавшего правила. Если доступ запрещен, слайс будет пустым — это сразу подсказывает, что проблема не в логике матчера, а в отсутствии подходящего правила (опечатка в policy.csv, не подтянулась роль через g, неправильный путь и т. п.).
В сложных случаях, когда нужно понять не только какое правило сработало, но и как выполнялся матчер, помогает EnableLog(true) — Casbin начнёт логировать каждый вызов Enforce с деталями: какие правила проверялись, какие роли подтянулись, какой получился итоговый эффект.
CSV-файлы хороши, пока сервис деплоится в одном экземпляре и политики правит сам разработчик. Как только появляется вторая реплика, всплывают три проблемы: каждая реплика работает со своим файлом, параллельные записи приводят к гонкам, а UI поверх CSV не построить. Все три закрываются переходом на адаптер для внешнего хранилища. Для PostgreSQL это выглядит так:
import ( "github.com/casbin/casbin/v2" gormadapter "github.com/casbin/gorm-adapter/v3" ) // Адаптер подключается к базе и автоматически создаёт таблицу casbin_rule a, err := gormadapter.NewAdapter("postgres", "host=localhost user=app password=secret dbname=myapp port=5432") if err != nil { log.Fatal(err) } // Передаём адаптер вместо пути к CSV-файлу e, err := casbin.NewEnforcer("config/model.conf", a)
После этого AddPolicy, RemovePolicy и SavePolicy работают напрямую с базой. Адаптеры есть для MySQL, MongoDB, Redis и т. д., полный список есть в документации Casbin.
Важный нюанс: сам по себе адаптер не синхронизирует политики между инстансами. Если один инстанс вызвал AddPolicy и записал правило в БД, остальные про него не узнают, пока не вызовут LoadPolicy() сами. Для двух-трёх реплик можно перечитывать политики по таймеру или после каждого изменения через админку. На большем количестве инстансов уже стоит подключить Watcher — компонент, который слушает изменения через Redis/Kafka/etcd и автоматически вызывает LoadPolicy на всех инстансах.
Политики — это бизнес-логика, записанная в другом формате. И раз она определяет, кто что может делать в системе, её нужно тестировать так же, как любой другой код. Табличные тесты Go подходят для этого идеально:
func TestACLPolicy(t *testing.T) { e, err := casbin.NewEnforcer("config/model.conf", "config/policy.csv") if err != nil { t.Fatalf("не удалось создать enforcer: %v", err) } tests := []struct { name string sub, obj, act string want bool }{ {"alice читает posts", "alice", "/posts", "read", true}, {"alice пишет в posts", "alice", "/posts", "write", true}, {"bob читает posts", "bob", "/posts", "read", true}, {"bob НЕ пишет в posts", "bob", "/posts", "write", false}, {"unknown user → default deny", "ghost", "/posts", "read", false}, {"unknown resource → deny", "alice", "/admin", "read", false}, {"unknown action → deny", "alice", "/posts", "delete", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := e.Enforce(tt.sub, tt.obj, tt.act) if err != nil { t.Fatalf("Enforce: %v", err) } if got != tt.want { t.Errorf("Enforce(%q, %q, %q) = %v, ожидалось %v", tt.sub, tt.obj, tt.act, got, tt.want) } }) } }
Такие тесты стоит запускать в CI и дописывать кейс при каждом изменении политики. Casbin не валидирует ни модель, ни правила — опечатка в policy.csv загрузится молча, и без тестов вы узнаете о ней по жалобам пользователей.
В начале статьи мы говорили про разрастающиеся if user.Role == "admin" по хендлерам и сервисам. Теперь у вас есть инструмент, который эту проблему закрывает: правила лежат в одном файле (или БД), бизнес-код их не видит, а единственная точка проверки — Enforce — даёт булево «можно или нельзя».
Поэкспериментировать с моделями удобно в онлайн-редакторе Casbin — он показывает результат матчинга прямо в браузере. Подробный справочник по моделям, адаптерам и функциям — в официальной документации. Код из статьи — на GitHub.
Автор alex_name_m
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS