Туториали

Reproducible Linux Server Provisioning: Scripting, Validation, and Audit Trails

Репродуктивно обезбедување на Linux сервери: скриптирање, валидација и ревизорски траги

Сервер што може повторно да се изгради е повреден од сервер што само преживеал. Рачните поправки акумулираат невидливи претпоставки: пакет инсталиран за време на инцидент, дозвола променета без запис или конфигурација копирана од друг хост. Месеци подоцна, машината работи, но никој не може да објасни зошто.

Ова упатство гради мал систем за обезбедување подготвен за продукција за посветен Debian 12 или Ubuntu 24.04 сервер. Тој инсталира Nginx, распоредува верзионирана крајна точка за проверка на здравјето на 127.0.0.1:8080, го валидира резултатот, бележи ревизорска трага и поддржува враќање назад. Повторените извршувања конвергираат кон истата состојба без непотребно рестартирање на здравите услуги.

Предуслови и оперативни граници

Користете тест виртуелна машина пред да насочите кон вистински хост. Скриптите намерно управуваат со стандардната локација на Nginx, па целта мора да биде посветен сервер без постоечки Nginx оптоварувања.

  • Debian 12 или Ubuntu 24.04 хост базиран на systemd
  • Root пристап преку sudo
  • Функционални складишта за пакети и DNS
  • Појдовен HTTP или HTTPS пристап за инсталација на пакети
  • Постоечка, независно тестирана SSH патека

Сите команди подолу се команди на хостот, а не команди за контејнер. Крајната точка се врзува само за loopback, па не бара отворање на влезниот заштитен ѕид. Не ја изложувајте со промена на адресата за слушање сè додека не се осмислени автентикација, TLS, ограничувања на стапката и изречни правила на заштитен ѕид.

Архитектура и компромиси

Системот ги одделува непроменливите изданија од променливата состојба на активирање. Секое издание се наоѓа под /opt/repro-node/releases. Симболичката врска /opt/repro-node/current го избира активното издание, овозможувајќи атомско префрлање со преименување во истиот датотечен систем.

Секое извршување создава трансакција под /var/lib/reprovision/transactions. Таа ги бележи старите и новите цели на изданието, како и резервните копии на конфигурацијата променета во текот на тоа извршување. Неблокирачкиот flock спречува истовремени provisioner-и да се натпреваруваат околу активната симболичка врска.

Враќањето назад ги покрива изданието на апликацијата и управуваната Nginx конфигурација. Намерно не деинсталира пакети: отстранувањето пакет за време на закрепнување може да активира скрипти на одржувачот, отстранување зависности и нарушување на неповрзани услуги. Затоа инсталацијата на пакети е трајна промена на ниво на хост, додека активирањето на изданието останува повратно.

Ревизорската датотека е само по конвенција append-only, а не криптографски заштитена од неовластени измени. Root може да ја измени. За посилен доказ, препратете ги соодветните journal настани во оддалечен лог-систем со ограничени контроли за задржување.

Структура на проектот

Создајте го овој проект на административна работна станица или директно во staging директориум во сопственост на root:

reprovision/
├── provision.sh
├── rollback.sh
├── validate.sh
└── files/
    ├── nginx-repro-node.conf
    └── public/
        └── health.json

Документот за здравјето е намерно статичен. Валидацијата на обезбедувањето не треба да зависи од база на податоци, надворешно API или runtime што воведува неповрзани режими на откажување.

{"status":"ok","service":"repro-node"}

Дефинирајте ја услугата Nginx

Зачувајте го следново како files/nginx-repro-node.conf. Врзувањето за loopback е важна безбедносна граница, а не само погодна стандардна вредност.

server {
    listen 127.0.0.1:8080;
    server_name _;
    server_tokens off;

    root /opt/repro-node/current/public;

    access_log /var/log/nginx/repro-node.access.log;
    error_log  /var/log/nginx/repro-node.error.log warn;

    location = /healthz {
        default_type application/json;
        add_header Cache-Control "no-store" always;
        add_header X-Content-Type-Options "nosniff" always;
        try_files /health.json =503;
    }

    location / {
        return 404;
    }
}

try_files враќа 503 ако активирањето покажува кон нецелосно издание. Барањата кон секоја друга патека добиваат 404, со што се намалува случајното изложување.

Имплементирајте идемпотентно обезбедување

Зачувајте ја оваа скрипта како provision.sh. Нејзиниот идентификатор на издание е изведен од управуваните датотеки, па непроменетиот влез повторно го користи истиот непроменлив директориум. Метаподатоците за пакетите се освежуваат само кога недостига потребен пакет.

#!/usr/bin/env bash
set -Eeuo pipefail

[[ $EUID -eq 0 ]] || { echo "Run as root" >&2; exit 1; }

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
BASE=/opt/repro-node
RELEASES=$BASE/releases
CURRENT=$BASE/current
TX_ROOT=/var/lib/reprovision/transactions
CONF=/etc/nginx/conf.d/repro-node.conf
DEFAULT_SITE=/etc/nginx/sites-enabled/default
AUDIT=/var/log/reprovision/audit.tsv
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$"
TX=$TX_ROOT/$RUN_ID
ARMED=0
STAGE=""

exec 9>/run/lock/reprovision.lock
flock -n 9 || { echo "Another provisioning run is active" >&2; exit 1; }

install -d -m 0755 "$BASE" "$RELEASES"
install -d -m 0700 "$TX_ROOT" "$TX"
install -d -m 0750 -o root -g adm "$(dirname "$AUDIT")"
touch "$AUDIT"
chown root:adm "$AUDIT"
chmod 0640 "$AUDIT"

audit() {
    printf '%s\t%s\t%s\t%s\t%s\n' \
        "$(date -u +%FT%TZ)" "$RUN_ID" "$1" "$2" "$3" >>"$AUDIT"
    logger -t reprovision -- "run=$RUN_ID action=$1 status=$2 detail=$3"
}

on_error() {
    local rc=$1
    trap - ERR
    set +e
    audit provision failure "exit-$rc"
    if [[ $ARMED -eq 1 ]]; then
        REPROVISION_LOCK_HELD=1 "$SCRIPT_DIR/rollback.sh" "$RUN_ID" --automatic
    fi
    if [[ -n $STAGE && $STAGE == "$RELEASES"/.staging.* ]]; then
        rm -rf -- "$STAGE"
    fi
    exit "$rc"
}
trap 'on_error $?' ERR

audit provision start requested

missing=()
for package in nginx curl; do
    dpkg-query -W -f='${Status}' "$package" 2>/dev/null |
        grep -qx 'install ok installed' || missing+=("$package")
done

if ((${#missing[@]})); then
    apt-get update
    DEBIAN_FRONTEND=noninteractive apt-get install -y \
        --no-install-recommends "${missing[@]}"
    audit packages changed "${missing[*]}"
else
    audit packages unchanged present
fi

RELEASE_ID="$(
    cd "$SCRIPT_DIR/files"
    sha256sum public/health.json nginx-repro-node.conf |
        sha256sum | awk '{print $1}'
)"
DEST=$RELEASES/$RELEASE_ID

if [[ ! -d $DEST ]]; then
    STAGE="$(mktemp -d "$RELEASES/.staging.XXXXXX")"
    install -d -m 0755 "$STAGE/public"
    install -m 0644 "$SCRIPT_DIR/files/public/health.json" \
        "$STAGE/public/health.json"
    mv -- "$STAGE" "$DEST"
    STAGE=""
    audit release created "$RELEASE_ID"
else
    audit release unchanged "$RELEASE_ID"
fi

if [[ -e $CURRENT && ! -L $CURRENT ]]; then
    echo "$CURRENT exists but is not a symlink" >&2
    exit 1
fi

previous="$(readlink -f "$CURRENT" 2>/dev/null || true)"
if [[ -n $previous && $previous != "$RELEASES"/* ]]; then
    echo "Refusing unmanaged current target: $previous" >&2
    exit 1
fi

printf '%s\n' "$previous" >"$TX/previous_target"
printf '%s\n' "$DEST" >"$TX/new_target"

if [[ -e $CONF ]]; then
    if cmp -s "$SCRIPT_DIR/files/nginx-repro-node.conf" "$CONF"; then
        touch "$TX/config.unchanged"
    else
        cp -a -- "$CONF" "$TX/nginx.conf"
        touch "$TX/config.changed"
    fi
else
    touch "$TX/config.absent"
fi

if [[ -L $DEFAULT_SITE ]]; then
    cp -a --no-dereference "$DEFAULT_SITE" "$TX/default-site"
    touch "$TX/default.changed"
elif [[ -e $DEFAULT_SITE ]]; then
    echo "Refusing to remove non-symlink $DEFAULT_SITE" >&2
    exit 1
fi

ARMED=1
install -m 0644 "$SCRIPT_DIR/files/nginx-repro-node.conf" "$CONF"

if [[ -f $TX/default.changed ]]; then
    rm -- "$DEFAULT_SITE"
fi

link_tmp=$BASE/.current-$RUN_ID
ln -s "$DEST" "$link_tmp"
mv -Tf -- "$link_tmp" "$CURRENT"
touch "$TX/activation.done"

nginx -t
if systemctl is-active --quiet nginx; then
    systemctl reload nginx
else
    systemctl enable --now nginx
fi

"$SCRIPT_DIR/validate.sh"
touch "$TX/success"
audit provision success "$RELEASE_ID"
trap - ERR
printf 'Provisioned release %s with transaction %s\n' "$RELEASE_ID" "$RUN_ID"

Замката за грешка се активира само откако трансакцијата има доволно информации за закрепнување. Неуспесите за време на инсталацијата на пакети или создавањето на издание ја оставаат претходно активната услуга недопрена.

Додајте контролирано враќање назад

Зачувајте го ова како rollback.sh. Рачното враќање назад е дозволено само кога активното издание сè уште одговара на изданието распоредено од трансакцијата. Таа заштита спречува стара трансакција тивко да пребрише поново распоредување.

#!/usr/bin/env bash
set -Eeuo pipefail

[[ $EUID -eq 0 ]] || { echo "Run as root" >&2; exit 1; }
[[ $# -ge 1 ]] || { echo "Usage: rollback.sh RUN_ID" >&2; exit 2; }

RUN_ID=$1
[[ $RUN_ID != *[^A-Za-z0-9._-]* ]] || { echo "Invalid run ID" >&2; exit 2; }

BASE=/opt/repro-node
CURRENT=$BASE/current
RELEASES=$BASE/releases
TX=/var/lib/reprovision/transactions/$RUN_ID
CONF=/etc/nginx/conf.d/repro-node.conf
DEFAULT_SITE=/etc/nginx/sites-enabled/default
AUDIT=/var/log/reprovision/audit.tsv

[[ -d $TX ]] || { echo "Unknown transaction: $RUN_ID" >&2; exit 1; }

if [[ ${REPROVISION_LOCK_HELD:-0} != 1 ]]; then
    exec 9>/run/lock/reprovision.lock
    flock -n 9 || { echo "Provisioning is active" >&2; exit 1; }
fi

previous="$(cat "$TX/previous_target")"
deployed="$(cat "$TX/new_target")"
current="$(readlink -f "$CURRENT" 2>/dev/null || true)"

if [[ -f $TX/activation.done ]]; then
    [[ $current == "$deployed" ]] || {
        echo "Active release no longer matches transaction" >&2
        exit 1
    }

    if [[ -n $previous ]]; then
        [[ $previous == "$RELEASES"/* && -d $previous ]] || {
            echo "Previous release is unavailable" >&2
            exit 1
        }
        tmp=$BASE/.rollback-$RUN_ID
        ln -s "$previous" "$tmp"
        mv -Tf -- "$tmp" "$CURRENT"
    else
        rm -- "$CURRENT"
    fi
fi

if [[ -f $TX/config.changed ]]; then
    install -m 0644 "$TX/nginx.conf" "$CONF"
elif [[ -f $TX/config.absent ]]; then
    rm -f -- "$CONF"
fi

if [[ -f $TX/default.changed ]]; then
    rm -f -- "$DEFAULT_SITE"
    cp -a --no-dereference "$TX/default-site" "$DEFAULT_SITE"
fi

nginx -t
if systemctl is-active --quiet nginx; then
    systemctl reload nginx
fi

printf '%s\t%s\trollback\tsuccess\t%s\n' \
    "$(date -u +%FT%TZ)" "$RUN_ID" "${previous:-no-release}" >>"$AUDIT"
logger -t reprovision -- "run=$RUN_ID action=rollback status=success"
printf 'Rolled back transaction %s\n' "$RUN_ID"

Nginx конфигурацијата се тестира пред повторното вчитување. Неуспешен тест го остава работниот Nginx процес со неговата претходна конфигурација во меморијата, иако датотечниот систем сепак мора да се испита. Чувајте ги директориумите за трансакции во сопственост на root и никогаш не прифаќајте архиви на трансакции од недоверливи извори.

Валидирајте ја конвергираната состојба

Зачувајте го ова како validate.sh. Валидацијата проверува синтакса на конфигурација, состојба на услуга, содржаност на изданието, сопственост, дозволи и вистинскиот HTTP одговор.

#!/usr/bin/env bash
set -Eeuo pipefail

CURRENT=/opt/repro-node/current
expected='{"status":"ok","service":"repro-node"}'

[[ -L $CURRENT ]] || { echo "Current release is not a symlink" >&2; exit 1; }
target="$(readlink -f "$CURRENT")"
[[ $target == /opt/repro-node/releases/* ]] || {
    echo "Current target escapes the releases directory" >&2
    exit 1
}
[[ -f $target/public/health.json ]] || {
    echo "Health document is missing" >&2
    exit 1
}

[[ "$(stat -c '%U:%G:%a' "$target/public/health.json")" == "root:root:644" ]] || {
    echo "Unexpected health document ownership or mode" >&2
    exit 1
}

nginx -t
systemctl is-active --quiet nginx

body="$(curl --fail --silent --show-error \
    --connect-timeout 2 --max-time 5 \
    http://127.0.0.1:8080/healthz)"

[[ $body == "$expected" ]] || {
    echo "Unexpected health response: $body" >&2
    exit 1
}

printf 'Validation passed for %s\n' "$target"

Времето за поврзување го ограничува воспоставувањето TCP; --max-time независно го ограничува целото барање. Едното не е замена за другото.

Распоредете и тестирајте

Од директориумот на проектот на хостот, инсталирајте ги датотеките во локација во сопственост на root и извршете го provisioner-от:

sudo install -d -m 0755 /srv/reprovision/files/public
sudo install -m 0755 provision.sh rollback.sh validate.sh /srv/reprovision/
sudo install -m 0644 files/nginx-repro-node.conf /srv/reprovision/files/
sudo install -m 0644 files/public/health.json /srv/reprovision/files/public/

sudo /srv/reprovision/provision.sh
curl --fail --silent --show-error http://127.0.0.1:8080/healthz
sudo /srv/reprovision/provision.sh
sudo tail -n 20 /var/log/reprovision/audit.tsv
sudo journalctl -t reprovision --since today

Второто извршување треба да пријави непроменет сет на пакети и издание, додека сепак извршува валидација. Тоа е корисен тест за идемпотентност: успехот значи конвергенција, а не „ништо не е извршено“.

Тестирајте враќање назад на VM што може да се отфрли така што ќе го промените health.json, повторно ќе обезбедите и ќе го предадете добиениот идентификатор на трансакција на sudo /srv/reprovision/rollback.sh RUN_ID. Потоа извршете validate.sh. Не симулирајте неуспех прво на продукциски хост.

Безбедност, набљудливост и перформанси

Чувајте го /srv/reprovision запишлив само за root или строго контролиран сметководствен запис за распоредување. Секој што може да ги измени овие скрипти може да добие ефекти на root-ниво при следното извршување. Прегледајте ги промените пред извршување и размислете за потпишување артефакти за распоредување во поголем систем.

Loopback слушателот ја задржува крајната точка надвор од мрежниот заштитен ѕид. Ако надворешниот пристап стане неопходен, додајте тесно ограничено правило на заштитен ѕид без бришење на постоечкиот сет правила на хостот. Зачувајте ги воспоставените врски и потврдете SSH allow правило пред да овозможите политика default-drop. Претпочитајте да го изложите Nginx преку автентициран reverse proxy наместо директно да ја отворите портата 8080.

Nginx логовите за пристап и грешки обезбедуваат видливост на ниво на барање; TSV датотеката и journal обезбедуваат настани за обезбедување. Поставете предупредување за неуспешни извршувања на обезбедување, повторени враќања назад, неуспешни Nginx валидации и латентност на проверка на здравјето. Препраќајте ги логовите надвор од хостот бидејќи локалниот доказ исчезнува со серверот.

Изданијата адресирани според содржината избегнуваат повторно копирање на непроменети артефакти, а проверките на пакети избегнуваат непотребни освежувања на складиштата. Глобалното заклучување ја сериализира ретката патека што менува состојба. Чистењето изданија треба да биде посебна, прегледана задача за задржување која никогаш не ја отстранува тековната цел или кое било издание на кое се повикуваат задржаните трансакции.

Вообичаени режими на откажување

  • Складиштата за пакети се недостапни: извршувањето запира пред активирањето, оставајќи го тековното издание недопрено.
  • Nginx веќе хостира апликации: запрете и приспособете го моделот на сопственост на конфигурацијата; ова упатство претпоставува посветен хост.
  • Тековната патека не е симболичка врска: скриптата одбива да замени неуправуван директориум.
  • Валидацијата враќа одбиена врска: проверете systemctl status nginx и Nginx логот за грешки.
  • Враќањето назад одбива трансакција: активно е поново распоредување или претходното издание е отстрането.
  • Отстапување на дозволите: валидацијата не успева пред да прогласи успех, правејќи го отстапувањето видливо во ревизорската трага.

Конечна листа за проверка

  • Provisioner-от успева двапати со истите влезови.
  • /opt/repro-node/current покажува во директориумот за изданија.
  • nginx -t успева и Nginx е активен.
  • Крајната точка за здравјето одговара само на 127.0.0.1:8080.
  • Одговорот точно одговара на очекуваниот JSON.
  • Ревизорскиот TSV и journal содржат настани за почеток, промена, успех и враќање назад.
  • Тестирана трансакција може да го врати претходното издание.
  • Проектните датотеки, трансакциите и привилегиите за распоредување се ограничени.
  • Пристапот до заштитниот ѕид и SSH е независно прегледан.

Репродуктивноста не се постигнува со ставање команди во shell датотека. Таа произлегува од изречна сопственост, детерминистички влезови, атомско активирање, ограничена валидација, чесни граници за враќање назад и доказ за секоја транзиција. Кога тие својства се осмислени заедно, повторното градење сервер престанува да биде чин на археологија и станува рутинска операција што може да се провери.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.