Выполняю тестовое задание для DevOps Cloud.ru Camp 2025

от автора

Я тут недавно пытался попасть на бесплатные курсы от Aston, но получил отказ с такой формулировкой.

Денис, добрый день!После изучения вашей анкеты, к сожалению, мы не готовы пригласить вас к дальнейшему рассмотрению на обучение в компании. У нас очень большой поток кандидатов на курсы. В данный момент взяли в рассмотрение участников с показателями выше. Ваша анкета будет сохранена,  возможно, вернемся к вашей кандидатуре, при наборе на следующий поток.Благодарим за внимание к нашей компании.

Хотя я даже не понял, как смогли оценить мои показатели и в чем они измерялись, если не давали ни тестового задания, ни собеседования. В анкете у них несколько вопросов и уровень образования. Но да ладно, это тема отдельной статьи.

Сегодня хочу попробовать пройти тестовое задание от клауд.ру. Оно охватывает тот самый стек для devops, linux, docker, git, kubernetes.

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

Тестовое задание состоит из 4 задач, начнем с первой.

Web приложение на Python/Golang

ЦельНаписать простейший echo-server на Python или GoЗадачаТребуется написать простое веб-приложение на Python/Go, которое слушает входящие соединения на порту 8000 и предоставляет страницу с информацией:    имя хоста    ip адрес хоста    имя автора, которое передаётся через переменную окружения $AUTHORДля приложения написать Dockerfile и запушить образ в докер хаб* (запушить в приватный регистри - пройти регистрацию в докер хаб, сделать регистри приватным).В ответе к задаче приложить:    исходный код    Dockerfile    команды, которые использовались для пуша образа в регистриПолученный артефакты положить в папку /01-application

На первом пункте уже кроется подвох. Python или Go , здесь нужно знать контекст, потому что при грамотной сборке на го, образ получится около 2 мб, вместо около 20 на пайтоне. С другой стороны, на питоне докерфайл писать легче, потому как мультистэйдж сборка здесь не прокатит.

Для чистоты эксперимента, я сделаю 2 варианта, начну с го.

Создаем папку 01-application, в ней 2 файла main.go, go.mod.

// main.gopackage mainimport ("fmt""net""net/http""os")func envOr(key, fallback string) string {if v := os.Getenv(key); v != "" {return v}return fallback}// IP контейнера в его собственном network namespace.func getContainerIP() string {conn, err := net.Dial("udp", "8.8.8.8:80")if err != nil {return "unknown"}defer conn.Close()return conn.LocalAddr().(*net.UDPAddr).IP.String()}func handler(w http.ResponseWriter, r *http.Request) {containerHost, _ := os.Hostname()   // имя из UTS namespace = ID контейнераcontainerIP := getContainerIP()     // адрес из network namespace// Данные ХОСТА не добываются — они приходят снаружи.hostHost := envOr("HOST_HOSTNAME", "(not injected)")hostIP := envOr("HOST_IP", "(not injected)")author := envOr("AUTHOR", "anonymous")w.Header().Set("Content-Type", "text/html; charset=utf-8")fmt.Fprintf(w, `<!DOCTYPE html><html><head><title>Echo Server</title></head><body style="font-family:monospace">  <h1>Echo Server</h1>  <ul>    <li><b>Container hostname:</b> %s</li>    <li><b>Container IP:</b> %s</li>    <li><b>Host hostname:</b> %s</li>    <li><b>Host IP:</b> %s</li>    <li><b>Author:</b> %s</li>  </ul></body></html>`, containerHost, containerIP, hostHost, hostIP, author)}func healthz(w http.ResponseWriter, r *http.Request) {fmt.Fprintln(w, "ok")}func main() {http.HandleFunc("/", handler)http.HandleFunc("/healthz", healthz)http.ListenAndServe(":8000", nil)}
// go.modmodule echo-servergo 1.23

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

# ---- Этап 1: сборка ----FROM golang:1.23-alpine AS builderWORKDIR /srcCOPY go.mod ./COPY main.go ./# CGO_ENABLED=0 — статическая линковка, чтобы бинарник# жил в scratch без единой системной библиотеки.# -ldflags="-s -w" срезает отладочные символы (меньше вес).RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /echo-server .RUN echo 'appuser:x:10001:10001::/:' > /etc/passwd# ---- Этап 2: финальный образ ----FROM scratchCOPY --from=builder /echo-server /echo-serverCOPY --from=builder /etc/passwd /etc/passwdUSER appuserEXPOSE 8000ENV AUTHOR="alfabuster"ENTRYPOINT ["/echo-server"]

Далее зарегаемся на докерхабе, если еще нет аккаунта, создаем репозиторий, делаем его приватным и создаем токен доступа.

# 1. Логинимся (введёт логин и Access Token — не пароль, пароли уже немодно)docker login -u <ваш_логин># 2. Собираем с правильным тегом: <логин>/<репозиторий>:<версия>docker build -t <ваш_логин>/echo-server:1.0 .# 3. Проверяем локально, что оно вообще дышитdocker run --rm -p 8001:8000 \  -e AUTHOR="alfabuster" \  -e HOST_HOSTNAME="$(hostname)" \  -e HOST_IP="$(ip -4 route get 1.1.1.1 | grep -oP 'src \K\S+')" \  <ваш_логин>/echo-server:1.0#    открываем http://localhost:8001 — должны увидеть hostname/IP/author

Если все сделали правильно, то увидим инфу о контейнере и хосте.

# 4. Пушим в приватный регистриdocker push <ваш_логин>/echo-server:1.0

Теперь проделаем, все тоже самое, но для пайтона.

В папке созаем server.py с таким кодом.

#!/usr/bin/env python3import osimport socketfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServerdef env_or(key, fallback):    return os.getenv(key) or fallbackdef get_container_ip():    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)    try:        s.connect(("8.8.8.8", 80))        return s.getsockname()[0]    except Exception:        return "unknown"    finally:        s.close()class Handler(BaseHTTPRequestHandler):    def do_GET(self):        if self.path == "/healthz":            self.send_response(200)            self.send_header("Content-Type", "text/plain")            self.end_headers()            self.wfile.write(b"ok\n")            return        container_host = socket.gethostname()      # ID контейнера        container_ip = get_container_ip()        host_host = env_or("HOST_HOSTNAME", "(not injected)")        host_ip = env_or("HOST_IP", "(not injected)")        author = env_or("AUTHOR", "anonymous")        body = f"""<!DOCTYPE html><html><head><title>Echo Server</title></head><body style="font-family:monospace">  <h1>Echo Server (Python)</h1>  <ul>    <li><b>Container hostname:</b> {container_host}</li>    <li><b>Container IP:</b> {container_ip}</li>    <li><b>Host hostname:</b> {host_host}</li>    <li><b>Host IP:</b> {host_ip}</li>    <li><b>Author:</b> {author}</li>  </ul></body></html>"""        self.send_response(200)        self.send_header("Content-Type", "text/html; charset=utf-8")        self.end_headers()        self.wfile.write(body.encode("utf-8"))    def log_message(self, *args):        passif __name__ == "__main__":    ThreadingHTTPServer(("0.0.0.0", 8003), Handler).serve_forever()

Напишем простой докерфайл. А теперь ключевой момент, ради которого всё затевалось. Помните элегантный финал Go-версии — FROM scratch, образ из чистой пустоты весом в мегабайты? С Python так не выйдет никогда. Python — язык интерпретируемый: чтобы запустить server.py, в образе обязан жить сам интерпретатор со всей его свитой. В пустоту его не положишь, multi-stage тут бесполезен — выкидывать нечего, рантайм нужен целиком.

FROM python:3.12-alpineWORKDIR /appRUN adduser -D appuser COPY --chown=appuser:appuser app.py .COPY server.py .EXPOSE 8003ENV AUTHOR="alfabuster"ENTRYPOINT [ "python3" , "server.py"]

Однако писать такой файл проще, всего 8 строк, которые по гайду из докера, напишет даже человек без профильного образования в IT.

Теперь сравним размер полученных из билда образов.

alt text

alt text

Как видно разница примерно в 10 раз. Здесь однозначно выигрывает go.

А что на счет безопасности? Образ на scratch — это крепость без дверей: там нет sh, нет apt, нет curl, нет вообще ничего, кроме бинарника. Злоумышленник, пробравшийся в такой контейнер, оказывается в пустой бетонной коробке — ни закрепиться, ни осмотреться, ни подтянуть инструменты. Однако scratch не является защитой от RCE, при определенных условиях можно использовать загруженные/встроенные бинарники или эксплуатировать уязвимости ядра/процесса.

В python:3.12-alpine sh присутсвует. Также содержит сам Python, поэтому при RCE атакующий может использовать Python как инструмент, даже если каких-то Unix-команд нет.

Справедливости ради — у Python есть и козыри. Dockerfile проще (никакой стадии сборки), правки на лету без перекомпиляции, а внутрь можно зайти и потыкать палкой, когда что-то пошло не так. Go-scratch за свою неприступность расплачивается тем, что отлаживать его — как чинить запечатанные часы: удобной дверцы нет. Это классический размен безопасность против удобства.

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

Тем временем первая задача выполнена, переходим ко второй.

Ansible playbook или роль

ЦельАвтоматизация конфигурации на AnsibleЗадачаТребуется поднять Виртуальную Машину (VM) с ОС Ubuntu 22.04. Возможные сценарии: локально через Virtual Box (или любое другое средство виртуализации), либо воспользовавшись Free-tier предложением от cloud.ru - https://cloud.ru/offers/free-tier.Выполнить следующие задачи, используя Ansible:    На VM установить пакет docker-ce;    Запустить на docker контейнеры c подготовленным Web-приложением из первой задачи в 3-х экземплярах. Для развёртывания использовать приватный регистри из первой задачи;    Настроить балансировщик на nginx, который будет отвечать при обращении к хосту и прокидывать запрос на запущенные контейнеры. Выбрать алгоритм балансировки для приложения и обосновать свой выбор.В ответе к задаче приложить:    исходный код Ansible    текстовое обоснование выбранного алгоритма балансировкиПолученные артефакты положить в папку /02-ansible

Ок, приступим. Поскольку хостовая система у меня Void Linux, вручную качать и устанавливать ubuntu, я не буду, воспользуюсь утилитой vagrant.

Для установки, установлю пакетный менеджер Brew.

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

Он рассчитан на bash, но у меня оболочка fish, поэтому, то что советует установщик в выводе в моем случае не сработает.

Нужно добавить Homebrew в PATH fish

fish_add_path /home/linuxbrew/.linuxbrew/binfish_add_path /home/linuxbrew/.linuxbrew/sbin

После этого можно устанавливать сам Vagrant.

brew tap hashicorp/tapbrew install hashicorp/tap/vagrant

На хосте создаю любую папку, в ней создаю файл Vagrantfile.

Vagrant.configure("2") do |config|  config.vm.box = "ubuntu/jammy64"  config.vm.network "private_network",  ip: "10.0.0.7",  virtualbox__intnet: "intnet"  config.vm.provider "virtualbox" do |vb|    vb.memory = 2048    vb.cpus   = 2  endend

Этим файлом я создал машину во внтуренней сети, которая у меня уже была.

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

Теперь можно создавать плейбук через Ansible.

В папке 02-ansible создаем пару файлов ansible.cfg и inventory.ini.

[defaults]inventory = inventory.iniremote_user = vagrantprivate_key_file = ~/.ssh/id_ed25519
[web]web-vm ansible_host=10.0.0.7[web:vars]ansible_python_interpreter=/usr/bin/python3

В этих файлах указываем, нашу созданную убунту через вагрант, ее ip ssh ключ и переменные.

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

Нам нужно, установить docker, залогиниться в приватный докер репозиторий, запустить 3 контейнера и накрыть балансировщиком на nginx.

Возможно руками, залезь на 1 сервак, там установить докер и запустить 3 контейнера будет быстрее. Но задание требует именно ansible, поэтому файл будет достаточно большим.

Начало плейбука

---- name: Deploy echo-server behind an nginx load balancer  hosts: web  become: true          # выполнять с sudo — иначе apt ругнется на permission  vars:    docker_image: "<ваш_логин>/echo-server:1.0"    author_name: "alfabuster"    container_count: 3    base_port: 8000                  # контейнеры сядут на 8001, 8002, 8003    registry_user: "<ваш_логин>"    registry_password: "{{ vault_registry_token }}"   # прячем в vault, создадим далее

Далее установим сам докер

  tasks:    - name: Install prerequisites      apt:        name: [ca-certificates, curl, gnupg, python3-pip]        state: present        update_cache: true    - name: Create apt keyrings directory      file:        path: /etc/apt/keyrings        state: directory        mode: '0755'    - name: Download Docker's GPG key      get_url:        url: https://download.docker.com/linux/ubuntu/gpg        dest: /etc/apt/keyrings/docker.asc        mode: '0644'    - name: Add Docker apt repository      apt_repository:        repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"        filename: docker        state: present    - name: Install docker-ce      apt:        name: [docker-ce, docker-ce-cli, containerd.io]        state: present        update_cache: true    - name: Install Docker SDK for Python      pip:        name: docker        state: present    - name: Ensure docker is running and enabled      service:        name: docker        state: started        enabled: true

Обратите внимание на ansible_distribution_release — это факт, который Ansible сам собирает о хосте перед стартом (для 22.04 подставит jammy).

Модуль docker_container работает не через CLI, а через Python-библиотеку docker. Поэтому мы ставим pip install docker — без неё модуль выдаст загадочную ошибку.

Приватный реджистри и запуск трех контейнеров

- name: Log in to the private registry  community.docker.docker_login:    username: "{{ registry_user }}"    password: "{{ registry_password }}"- name: Pull the application image  community.docker.docker_image:    name: "{{ docker_image }}"    source: pull- name: Run echo-server containers  community.docker.docker_container:    name: "echo-{{ item }}"    image: "{{ docker_image }}"    state: started    restart_policy: unless-stopped    published_ports:      - "127.0.0.1:{{ item }}:8000"    env:      AUTHOR: "{{ author_name }}"  loop:    - 8001    - 8002    - 8003

published_ports привязан к 127.0.0.1, а не к 0.0.0.0. Контейнеры слушают только локально — снаружи к ним не достучаться, единственная дверь наружу — через nginx.

Nginx и его конфиг через шаблон.

    - name: Install nginx      apt:        name: nginx        state: present    - name: Deploy the load-balancer config      template:        src: nginx-lb.conf.j2        dest: /etc/nginx/sites-available/echo-lb.conf      notify: Reload nginx    - name: Enable the site      file:        src: /etc/nginx/sites-available/echo-lb.conf        dest: /etc/nginx/sites-enabled/echo-lb.conf        state: link      notify: Reload nginx    - name: Remove the default nginx site      file:        path: /etc/nginx/sites-enabled/default        state: absent      notify: Reload nginx    - name: Ensure nginx is running and enabled      service:        name: nginx        state: started        enabled: true  handlers:    - name: Reload nginx      service:        name: nginx        state: reloaded

handlers — механизм, который отличает Ansible от тупого шелл-скрипта. notify: Reload nginx не перезагружает nginx немедленно. Он ставит галочку «надо будет перечитать конфиг» и делает это один раз в конце, и только если конфиг реально изменился.

Шаблон templates/nginx-lb.conf.j2

upstream echo_backend {    server 127.0.0.1:8001;    server 127.0.0.1:8002;    server 127.0.0.1:8003;}server {    listen 80 default_server;    server_name _;    location / {        proxy_pass http://echo_backend;        proxy_set_header Host              $host;        proxy_set_header X-Real-IP         $remote_addr;        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;    }}

Обоснование алгоритма

А теперь ради чего всё затевалось. У nginx есть несколько алгоритмов балансировки, и вот полный расклад, кто есть кто:

  • round-robin (по умолчанию) — раздаёт запросы по кругу: первый на 8001, второй на 8002, третий на 8003, четвёртый снова на 8001.

  • least_conn — отдаёт тому бэкенду, у кого меньше активных соединений.

  • ip_hash — привязывает клиента к бэкенду по хешу его IP (липкие сессии).

  • hash / random — экзотика для особых случаев.

Поскольку у нас простое stateless приложение, нет ни куков, ни личного кабинета, ни сессий. Все 3 контейнера равнозначны и идентичны. Поэтому равномерное распределение по кругу будет оптимальным вариантом в данной ситуации.

Least_conn здесь не станет преимуществом, потому как нагрзука в условии не указана. ip_hash в данной топологии противопоказан, так как весь трафик приходит с localhost и был бы прикован к одному контейнеру.

Поэтому выбираем round-robin.

Создаем vault и запускаем плейбук

Создание vault и пароля к нему.

ansible-vault create group_vars/web/vault.yml

Внутри одна строка с токеном из докера

vault_registry_token: "docker_token"

Далее можем запускать наш плейбук.

ansible-playbook playbook.yaml --ask-vault-pass

Тут не все прошло гладко, вылезла ошибка.

fatal: [web-vm]: FAILED! => {"changed": false, "msg": "Error connecting: Error while fetching server API version: Not supported URL scheme http+docker"}

Это произошло потому что, я давно не обновлял коллекции из ansible. Чиним.

ansible-galaxy collection install community.docker --upgrade

Запускаем плейбук еще раз и все заработало.

Проверяем сервак.

Балансировщик работает, второе задание выполнено.

Kubernetes manifest

ЦельДеплой приложения в KubernetesЗадачаТребуется написать манифест для запуска приложения из первой задачи в Kubernetes в отдельном неймспейсе в виде Deployment с 3 репликами и сервиса с типом ClusterIP.Выполнить следующие задачи:    Для приложения организовать проброс значения переменной AUTHOR;    Реализовать readiness- и liveness- пробы;    Использовать образ из приватного регистри из задачи 1;    опционально: поднять ingress-controller и создать правило ingress;    опционально: использовать helm-chart, а не raw манифест.В ответе к задаче приложить:    исходный код всех манифестовПолученные артефакты положить в папку /03-kubernetes

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

У меня на убунту для тестов как раз стоит кубер и 2 воркер ноды версии v1.31.14. Поэтому я применю написанные манифесты.

Недавно проходил курс Certified Kubernetes Administrator от kodekloud, там подсмотрел полезную тему для генерации манифестов.

Во первых, пропишем алиас, чтобы каждый раз не вводить kubectl.

alias k=kubectl

Во вторых, почти любой объект kubectl create умеет напечатать готовый манифест вместо создания.

--dry-run=client -o yaml

Дальше можно воспользоваться перенаправлением в файл и у нас будет готовый шаблон манифеста, с правильными отступами по канонам yaml.

Создаем namespace

k create ns cloud --dry-run=client -o yaml

В выводе будет лежать готовый манифест.

apiVersion: v1kind: Namespacemetadata:  creationTimestamp: null  name: cloudspec: {}status: {}

Конечно врядли, кто пишет манифест для создания namespace, его проще создать командой k create ns name. Однако в задании нужен именно манифест.

Секрет

Задача усложняется тем, что реджистри приватный. Для него необходимо создавать секрет. Иначе получим вечный ImagePullBackOff.

Создаётся секрет императивно, чтобы не хранить токен в git.

kubectl create secret docker-registry regcred \  --docker-server=https://index.docker.io/v1/ \  --docker-username=<ваш_логин> \  --docker-password=<ваш_access_token> \  --namespace=cloud

Секрет живёт внутри неймспейса cloud — в другом неймспейсе он невидим.

Configmap

Можно было впихнуть AUTHOR прямо в манифест как value: “alfabuster”. Но задание просит «организовать проброс», а по-взрослому переменные конфигурации живут в ConfigMap — отдельно от кода деплоя:

apiVersion: v1kind: ConfigMapmetadata:  creationTimestamp: null  name: cloud-configmap  namespace: clouddata:  AUTHOR: alfabuster

Deployment

Собственно дальше идет деплоймент с 3 репликами и пробами. Генерируем шаблон, а дальше правим руками.

k create deploy cloud-deploy --image=alfabuster/echo-server:1.0 --replicas=3 --port=8000 -n cloud --dry-run=client -o yaml

Итоговый манифест получился такой.

apiVersion: apps/v1kind: Deploymentmetadata:  creationTimestamp: null  labels:    app: cloud-deploy  name: cloud-deploy  namespace: cloudspec:  replicas: 3  selector:    matchLabels:      app: cloud-deploy  strategy: {}  template:    metadata:      creationTimestamp: null      labels:        app: cloud-deploy    spec:      imagePullSecrets:        - name: regcred      containers:      - image: alfabuster/echo-server:1.0        name: echo-server        ports:        - containerPort: 8000        env:          - name: AUTHOR            valueFrom:              configMapKeyRef:                name: cloud-configmap                key: AUTHOR        livenessProbe:          httpGet:            path: /healthz            port: 8000          initialDelaySeconds: 10          periodSeconds: 5          timeoutSeconds: 3          failureThreshold: 3        readinessProbe:          httpGet:            path: /healthz            port: 8000          periodSeconds: 5
  • livenessProbe отвечает на вопрос «пациент вообще жив?». Если проба падает — Kubernetes перезапускает контейнер. Это лекарство от зависших процессов и дедлоков.

  • readinessProbe отвечает на вопрос «пациент готов принимать посетителей?». Если проба падает — под не перезапускается, его просто исключают из эндпоинтов Service, то есть перестают слать трафик, пока он не оклемается.

Service

Service в Kubernetes — это тоже балансировщик. ClusterIP раскидывает входящие соединения по всем готовым подам.

apiVersion: v1kind: Servicemetadata:  name: cloud-deploy  namespace: cloudspec:  type: ClusterIP  selector:    app: cloud-deploy  ports:    - port: 80      targetPort: 8000      protocol: TCP

Ingress

Создаем уже привычным способом, через генератор.

k create ingress cloud-ingress --rule="cloud.local/*=cloud-deploy:80" -n cloud --dry-run=client -o yaml
apiVersion: networking.k8s.io/v1kind: Ingressmetadata:  creationTimestamp: null  name: cloud-ingress  namespace: cloudspec:  rules:  - host: cloud.local    http:      paths:      - backend:          service:            name: cloud-deploy            port:              number: 80        path: /        pathType: Prefixstatus:  loadBalancer: {}

Домен cloud.local надо прописать в /etc/hosts, указав на IP кластера (minikube ip или адрес ноды). Без этой записи браузер будет искать cloud.local в интернете.

alt text

alt text

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

alt text

alt text

Запереть контейнер изнутри*

ЦельПроверить навыки администрирования *nixЗадача    Создать ВМ с ОС Ubuntu 22.04;    Установить пакет docker-ce;    Выполнить команду docker run -it --rm ubuntu -- bash;    Не покидая контейнер, не меняя флаги запуска и настройки демона, выполнить задачи:    4.1. сделать apt-get update, показать, что команда выполняется успешно;    4.2. изнутри контейнера заблокировать ему выход в сеть интернет, команды блокировки с описанием приложить к задаче;    4.3. показать, что apt-get update больше не работает.    Доп. задача - найти ещё +1 способ выполнить подобную блокировку изнутри контейнера.В ответе к задаче приложить:    текстовое описание применённых конфигурацийПолученные артефакты положить в папку /04-unix

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

alt text

alt text

Вот в чём фокус: Docker по умолчанию запускает контейнеры с урезанным набором Linux capabilities, выбрасывая большинство привилегий, даже когда внутри вы root.

Root внутри контейнера не равняется рут на хосте.

Из очевидного в голову приходит установить iptables и запретить исходящий трафик. Установить можем, однако для правки таблицей маршрутизации рута недостаточно.

root@d79ba688c2c9:/# iptables -A OUTPUT -j DROPiptables v1.8.11 (nf_tables): Could not fetch rule set generation id: Permission denied (you must be root)

Контейнер работает под root, но демон docker по умолчанию сбрасывает все capabilities, кроме дефолтного набора. А в таком списке отсутсвует привелегия NET_ADMIN, без которой вы не можете ни трогать iptables, ни гасить интерфейсы, ни править таблицу маршрутизации.

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

Нам нужно заблокировать интернет именно для утилиты apt.

Способ №1 — nsswitch.conf

Начну не с самого очевидного варианта файла /etc/nsswitch.conf, я о нем сам недавно узнал, принимая участие в одном CTF.

NSS расшифровывается как Name Service Switch. Это механизм, встроенный в стандартную библиотеку C (glibc). Когда любая программа пытается узнать IP-адрес по доменному имени или проверить, существует ли в системе определенный пользователь, она обращается к glibc. А glibc первым делом читает /etc/nsswitch.conf, чтобы понять, где именно искать ответ.

В структуре этого файла есть строка hosts: files dns

Убрав dns из строки hosts:, вы вообще запрещаете системе обращаться к DNS — резолвиться будет только то, что явно прописано в /etc/hosts.

Кстати NSS управляет не только хостами. В файле прописаны правила для десятка системных баз данных.

Но в данном таске нас интересует dns, поэтому редактируем файл и получаем неработающий apt.

alt text

alt text

Способ №2 — resolv.conf

Второй способ, аналогичен первому, только на этот раз править будем файл /etc/resolv.conf.

Если /etc/nsswitch.conf решает, нужно ли вообще делать DNS-запрос, то /etc/resolv.conf указывает системе, к кому именно с ним обращаться. Это главный конфигурационный файл DNS-резолвера. Он часто генерируется networkmanager’ом, поэтому при следующем подключении вероятнее всего он затрется, но для демонстрации в контейнере, хватит обычной правки через vim или nano.

> /etc/resolv.conf
alt text

alt text

Снова получаем неработающий интернет.

Способ 3 — /etc/hosts

/etc/hosts — это простейший локальный справочник, который жестко связывает IP-адреса с доменными именами.

В него можно прописать локальный ip адрес серверов убунту и обновление не пройдет.

127.0.0.1 archive.ubuntu.com127.0.0.1 security.ubuntu.com127.0.0.1 ports.ubuntu.com
alt text

alt text

Способ 4 — apt proxy

У APT есть собственная настройка HTTP/HTTPS-прокси. Можно попробовать для всех HTTP-запросов использовать прокси 127.0.0.1:9.

Порт 9 используется для протокола Discard (RFC 863), который является сетевым эквивалентом черной дыры (/dev/null). Любые данные, отправленные на этот порт по TCP или UDP, просто уничтожаются сервером без какого-либо ответа или подтверждения.

cat > /etc/apt/apt.conf.d/99-block-network <<'EOF'Acquire::http::Proxy "http://127.0.0.1:9";Acquire::https::Proxy "http://127.0.0.1:9";EOF
alt text

alt text

Как итог

Получилось неплохое задание, мне понравилось его выполнять. Много чего в памяти освежил, некоторые вещи изучил. Задание не сказать, чтобы сильно простое, но и не сложное. Некоторые задания потребуют необычного подхода и глубокое знание linux.

Выполнил и выложил на гитхаб alfabuster.

ссылка на оригинал статьи https://habr.com/ru/articles/1082220/