Add platform infrastructure configuration
This commit is contained in:
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash infrastructure/networking/traefik/scripts/apply-observe.sh --execute
|
||||
|
||||
Applies JSON access logging to the k3s-managed Traefik HelmChartConfig.
|
||||
It does not trust any forwarded header. The existing loopback-only
|
||||
NodePort 30080/30443 boundary is declared explicitly so Helm reconciliation
|
||||
cannot replace it with chart defaults.
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
require_commands kubectl jq rg curl nc python3 awk cmp find bash
|
||||
|
||||
bash "${SCRIPT_DIR}/validate.sh"
|
||||
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-observe.XXXXXX")"
|
||||
rollback_required=false
|
||||
selected_context=""
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
|
||||
trap - EXIT
|
||||
if [[ "$exit_code" -ne 0 && "$rollback_required" == "true" ]]; then
|
||||
printf '\nROLLBACK: restoring the durable NodePort-only baseline.\n' >&2
|
||||
set +e
|
||||
kubectl --context "$selected_context" apply --kustomize "$BASELINE_OVERLAY"
|
||||
wait_for_runtime baseline
|
||||
kubectl --context "$selected_context" --namespace kube-system \
|
||||
rollout status deployment/traefik --timeout=5m
|
||||
wait_for_nodeport_boundary_and_health
|
||||
printf 'ROLLBACK complete. NodePort 30080/30443 remains pinned.\n' >&2
|
||||
set -e
|
||||
fi
|
||||
|
||||
case "$work_dir" in
|
||||
/tmp/platform-traefik-observe.*|"${TMPDIR:-/tmp}"/platform-traefik-observe.*)
|
||||
rm -rf -- "$work_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$work_dir" >&2
|
||||
;;
|
||||
esac
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
observe_render="${work_dir}/observe.yaml"
|
||||
render_overlay "$OBSERVE_OVERLAY" "$observe_render"
|
||||
|
||||
assert_live_baseline
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
selected_context="$(current_context)"
|
||||
selected_api_server="$(current_api_server)"
|
||||
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
|
||||
"$selected_context" "$selected_api_server" "$TARGET_NODE"
|
||||
|
||||
already_applied=false
|
||||
if kubectl --namespace kube-system \
|
||||
get helmchartconfig.helm.cattle.io traefik >/dev/null 2>&1; then
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/live-observe-compare.yaml"
|
||||
already_applied=true
|
||||
printf 'The live HelmChartConfig already matches the observation overlay.\n'
|
||||
fi
|
||||
|
||||
printf 'Type APPLY %s OBSERVE to enable JSON access logs: ' "$selected_context"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY ${selected_context} OBSERVE" ]] || \
|
||||
fail "cancelled"
|
||||
|
||||
[[ "$(current_context)" == "$selected_context" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(current_api_server)" == "$selected_api_server" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
assert_live_baseline
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
if [[ "$already_applied" == "false" ]]; then
|
||||
rollback_required=true
|
||||
kubectl apply --filename "$observe_render"
|
||||
fi
|
||||
|
||||
wait_for_runtime observe
|
||||
kubectl --namespace kube-system rollout status deployment/traefik --timeout=5m
|
||||
assert_runtime observe
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/post-apply-observe.yaml"
|
||||
assert_live_baseline
|
||||
wait_for_nodeport_boundary_and_health
|
||||
|
||||
manifest_start_url="$(
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
"$GITEA_MANIFEST_URL" |
|
||||
jq --raw-output '.start_url'
|
||||
)"
|
||||
|
||||
rollback_required=false
|
||||
printf '\nOBSERVATION PHASE READY\n'
|
||||
printf 'Traefik JSON access logging: enabled\n'
|
||||
printf 'forwardedHeaders trust: absent\n'
|
||||
printf 'Current Gitea manifest start_url: %s\n' "$manifest_start_url"
|
||||
printf 'Next: bash %s/observe-client-host.sh\n' "$SCRIPT_DIR"
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash infrastructure/networking/traefik/scripts/apply-trust.sh \
|
||||
--observed-client-host <IP> --execute
|
||||
|
||||
The IP must be the ClientHost printed by observe-client-host.sh. The trust
|
||||
overlay must already contain that exact IP as /32 (IPv4) or /128 (IPv6).
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "$#" -eq 3 && "$1" == "--observed-client-host" && "$3" == "--execute" ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
readonly REVIEWED_CLIENT_HOST="$2"
|
||||
|
||||
require_commands kubectl jq rg curl nc python3 awk sort date cmp find bash
|
||||
|
||||
reviewed_cidr="$(host_to_exact_cidr "$REVIEWED_CLIENT_HOST")"
|
||||
declared_cidr="$(source_trusted_proxy_cidr)"
|
||||
declared_cidr="$(normalize_exact_host_cidr "$declared_cidr")"
|
||||
|
||||
[[ "$declared_cidr" != "$SENTINEL_TRUSTED_PROXY_CIDR" ]] || \
|
||||
fail "trust overlay still contains the non-routable sentinel CIDR"
|
||||
[[ "$declared_cidr" == "$reviewed_cidr" ]] || \
|
||||
fail "declared CIDR ${declared_cidr} does not match ClientHost ${REVIEWED_CLIENT_HOST}"
|
||||
|
||||
bash "${SCRIPT_DIR}/validate.sh"
|
||||
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-trust.XXXXXX")"
|
||||
rollback_required=false
|
||||
selected_context=""
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
|
||||
trap - EXIT
|
||||
if [[ "$exit_code" -ne 0 && "$rollback_required" == "true" ]]; then
|
||||
printf '\nROLLBACK: restoring the access-log-only observation overlay.\n' >&2
|
||||
set +e
|
||||
kubectl --context "$selected_context" apply --kustomize "$OBSERVE_OVERLAY"
|
||||
wait_for_runtime observe
|
||||
kubectl --context "$selected_context" --namespace kube-system \
|
||||
rollout status deployment/traefik --timeout=5m
|
||||
wait_for_nodeport_boundary_and_health
|
||||
printf 'ROLLBACK complete. Forwarded-header trust removal was requested.\n' >&2
|
||||
set -e
|
||||
fi
|
||||
|
||||
case "$work_dir" in
|
||||
/tmp/platform-traefik-trust.*|"${TMPDIR:-/tmp}"/platform-traefik-trust.*)
|
||||
rm -rf -- "$work_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$work_dir" >&2
|
||||
;;
|
||||
esac
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
trust_render="${work_dir}/trust.yaml"
|
||||
render_overlay "$TRUST_OVERLAY" "$trust_render"
|
||||
|
||||
assert_live_baseline
|
||||
assert_runtime observe
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/live-observe.yaml"
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
fresh_client_host="$(observe_host_nginx_client_host)"
|
||||
fresh_cidr="$(host_to_exact_cidr "$fresh_client_host")"
|
||||
[[ "$fresh_cidr" == "$reviewed_cidr" ]] || \
|
||||
fail "fresh ClientHost ${fresh_client_host} differs from reviewed ${REVIEWED_CLIENT_HOST}"
|
||||
|
||||
selected_context="$(current_context)"
|
||||
selected_api_server="$(current_api_server)"
|
||||
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
|
||||
"$selected_context" "$selected_api_server" "$TARGET_NODE"
|
||||
printf 'Fresh ClientHost: %s\nExact trusted CIDR: %s\n' \
|
||||
"$fresh_client_host" "$declared_cidr"
|
||||
printf 'Type APPLY %s TRUST %s to continue: ' \
|
||||
"$selected_context" "$declared_cidr"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY ${selected_context} TRUST ${declared_cidr}" ]] || \
|
||||
fail "cancelled"
|
||||
|
||||
[[ "$(current_context)" == "$selected_context" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(current_api_server)" == "$selected_api_server" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
assert_live_baseline
|
||||
assert_runtime observe
|
||||
assert_nodeport_boundary_and_health
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/pre-apply-observe.yaml"
|
||||
|
||||
rollback_required=true
|
||||
kubectl apply --filename "$trust_render"
|
||||
|
||||
wait_for_runtime trust "$declared_cidr"
|
||||
kubectl --namespace kube-system rollout status deployment/traefik --timeout=5m
|
||||
assert_runtime trust "$declared_cidr"
|
||||
assert_live_hcc_matches_overlay "$TRUST_OVERLAY" \
|
||||
"${work_dir}/post-apply-trust.yaml"
|
||||
assert_live_baseline
|
||||
wait_for_nodeport_boundary_and_health
|
||||
assert_manifest_https
|
||||
|
||||
rollback_required=false
|
||||
printf '\nTRUST PHASE READY\n'
|
||||
printf 'Traefik web trusted CIDR: %s\n' "$declared_cidr"
|
||||
printf 'Traefik websecure trusted CIDR: absent\n'
|
||||
printf 'forwardedHeaders.insecure: absent\n'
|
||||
printf 'Gitea health and HTTPS site-manifest checks: PASS\n'
|
||||
printf 'Repeat the 30080/30443 refusal check from a separate LAN client.\n'
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 이 파일은 같은 디렉터리의 실행 스크립트에서만 source한다.
|
||||
|
||||
readonly TRAEFIK_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)"
|
||||
readonly BASELINE_OVERLAY="${TRAEFIK_ROOT}/overlays/baseline"
|
||||
readonly OBSERVE_OVERLAY="${TRAEFIK_ROOT}/overlays/observe"
|
||||
readonly TRUST_OVERLAY="${TRAEFIK_ROOT}/overlays/trust"
|
||||
readonly TRUST_PATCH="${TRUST_OVERLAY}/trusted-proxy-cidr-patch.yaml"
|
||||
readonly SENTINEL_TRUSTED_PROXY_CIDR="192.0.2.1/32"
|
||||
|
||||
readonly TARGET_NODE="donghyeon-system-product-name"
|
||||
readonly K3S_NODEPORT_CONFIG="/etc/rancher/k3s/config.yaml.d/30-nodeport-loopback.yaml"
|
||||
readonly EXPECTED_K3S_CHART="https://%{KUBERNETES_API}%/static/charts/traefik-40.1.3+up40.1.0.tgz"
|
||||
readonly EXPECTED_CHART_LABEL="traefik-40.1.3_up40.1.0"
|
||||
readonly EXPECTED_TRAEFIK_IMAGE="rancher/mirrored-library-traefik:3.7.4"
|
||||
readonly GITEA_HOST="git.learn.hyeonworks.com"
|
||||
readonly GITEA_HEALTH_URL="http://127.0.0.1:30080/api/healthz"
|
||||
readonly GITEA_HTTPS_HEALTH_URL="https://${GITEA_HOST}/api/healthz"
|
||||
readonly GITEA_MANIFEST_URL="https://${GITEA_HOST}/assets/site-manifest.json"
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
require_commands() {
|
||||
local command_name
|
||||
|
||||
for command_name in "$@"; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || \
|
||||
fail "${command_name} is required"
|
||||
done
|
||||
}
|
||||
|
||||
render_overlay() {
|
||||
local overlay="$1"
|
||||
local output="$2"
|
||||
|
||||
kubectl kustomize "$overlay" >"$output"
|
||||
[[ -s "$output" ]] || fail "rendered manifest is empty: ${overlay}"
|
||||
}
|
||||
|
||||
manifest_values_content() {
|
||||
local manifest="$1"
|
||||
|
||||
awk '
|
||||
/^ valuesContent: \|-$/ {
|
||||
found = 1
|
||||
next
|
||||
}
|
||||
found {
|
||||
sub(/^ /, "")
|
||||
print
|
||||
}
|
||||
' "$manifest"
|
||||
}
|
||||
|
||||
source_trusted_proxy_cidr() {
|
||||
local -a values=()
|
||||
|
||||
mapfile -t values < <(
|
||||
awk -F'"' '/^[[:space:]]*-[[:space:]]*"/ { print $2 }' "$TRUST_PATCH"
|
||||
)
|
||||
[[ "${#values[@]}" -eq 1 ]] || \
|
||||
fail "trust patch must contain exactly one quoted trusted CIDR"
|
||||
printf '%s\n' "${values[0]}"
|
||||
}
|
||||
|
||||
host_to_exact_cidr() {
|
||||
local host="$1"
|
||||
|
||||
python3 - "$host" <<'PY'
|
||||
import ipaddress
|
||||
import sys
|
||||
|
||||
value = sys.argv[1]
|
||||
if "/" in value:
|
||||
raise SystemExit("ClientHost must be one IP address, not a CIDR")
|
||||
|
||||
address = ipaddress.ip_address(value)
|
||||
if address.is_unspecified or address.is_multicast:
|
||||
raise SystemExit("ClientHost cannot be unspecified or multicast")
|
||||
|
||||
prefix = 32 if address.version == 4 else 128
|
||||
print(f"{address.compressed}/{prefix}")
|
||||
PY
|
||||
}
|
||||
|
||||
normalize_exact_host_cidr() {
|
||||
local cidr="$1"
|
||||
|
||||
python3 - "$cidr" <<'PY'
|
||||
import ipaddress
|
||||
import sys
|
||||
|
||||
network = ipaddress.ip_network(sys.argv[1], strict=True)
|
||||
required_prefix = 32 if network.version == 4 else 128
|
||||
if network.prefixlen != required_prefix:
|
||||
raise SystemExit(
|
||||
f"trusted proxy range must be one exact host /{required_prefix}, "
|
||||
f"not {network.with_prefixlen}"
|
||||
)
|
||||
print(network.with_prefixlen)
|
||||
PY
|
||||
}
|
||||
|
||||
current_context() {
|
||||
kubectl config current-context
|
||||
}
|
||||
|
||||
current_api_server() {
|
||||
kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}'
|
||||
}
|
||||
|
||||
deployment_args() {
|
||||
kubectl --namespace kube-system get deployment traefik --output=json |
|
||||
jq --raw-output '
|
||||
.spec.template.spec.containers[]
|
||||
| select(.name == "traefik")
|
||||
| .args[]
|
||||
'
|
||||
}
|
||||
|
||||
runtime_matches() {
|
||||
local mode="$1"
|
||||
local trusted_cidr="${2:-}"
|
||||
local args
|
||||
|
||||
args="$(deployment_args 2>/dev/null)" || return 1
|
||||
if rg --quiet --ignore-case -- 'forwardedheaders\.insecure' <<<"$args"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$mode" != "baseline" ]]; then
|
||||
rg --quiet --fixed-strings --line-regexp -- '--accesslog=true' <<<"$args" || \
|
||||
return 1
|
||||
rg --quiet --fixed-strings --line-regexp -- '--accesslog.format=json' <<<"$args" || \
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$mode" in
|
||||
observe)
|
||||
! rg --quiet --ignore-case -- 'forwardedheaders\.trustedips' <<<"$args"
|
||||
;;
|
||||
trust)
|
||||
rg --quiet --fixed-strings --line-regexp -- \
|
||||
"--entryPoints.web.forwardedHeaders.trustedIPs=${trusted_cidr}" <<<"$args" || \
|
||||
return 1
|
||||
! rg --quiet --ignore-case -- \
|
||||
'entrypoints\.websecure\.forwardedheaders\.(trustedips|insecure)' <<<"$args"
|
||||
;;
|
||||
baseline)
|
||||
! rg --quiet --ignore-case -- \
|
||||
'accesslog|forwardedheaders\.(trustedips|insecure)' <<<"$args"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
assert_runtime() {
|
||||
local mode="$1"
|
||||
local trusted_cidr="${2:-}"
|
||||
local args
|
||||
local trusted_count
|
||||
|
||||
args="$(deployment_args)"
|
||||
rg --quiet --fixed-strings --line-regexp -- '--accesslog=true' <<<"$args" || \
|
||||
fail "Traefik runtime is missing --accesslog=true"
|
||||
rg --quiet --fixed-strings --line-regexp -- '--accesslog.format=json' <<<"$args" || \
|
||||
fail "Traefik runtime is missing JSON access-log format"
|
||||
if rg --quiet --ignore-case -- 'forwardedheaders\.insecure' <<<"$args"; then
|
||||
fail "forwardedHeaders.insecure must never be present"
|
||||
fi
|
||||
|
||||
trusted_count="$(
|
||||
rg --count --ignore-case -- 'forwardedheaders\.trustedips' <<<"$args" || true
|
||||
)"
|
||||
trusted_count="${trusted_count:-0}"
|
||||
|
||||
case "$mode" in
|
||||
observe)
|
||||
[[ "$trusted_count" == "0" ]] || \
|
||||
fail "observation phase must not trust forwarded headers"
|
||||
;;
|
||||
trust)
|
||||
[[ "$trusted_count" == "1" ]] || \
|
||||
fail "trust phase must render exactly one trustedIPs argument"
|
||||
rg --quiet --fixed-strings --line-regexp -- \
|
||||
"--entryPoints.web.forwardedHeaders.trustedIPs=${trusted_cidr}" <<<"$args" || \
|
||||
fail "web entrypoint does not contain the reviewed exact-host CIDR"
|
||||
if rg --quiet --ignore-case -- \
|
||||
'entrypoints\.websecure\.forwardedheaders\.(trustedips|insecure)' <<<"$args"; then
|
||||
fail "websecure must not receive forwarded-header trust"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
fail "unsupported runtime assertion mode: ${mode}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
wait_for_runtime() {
|
||||
local mode="$1"
|
||||
local trusted_cidr="${2:-}"
|
||||
local attempt
|
||||
|
||||
for ((attempt = 1; attempt <= 120; attempt++)); do
|
||||
if runtime_matches "$mode" "$trusted_cidr"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fail "Traefik runtime did not reach ${mode} state within 240 seconds"
|
||||
}
|
||||
|
||||
assert_live_baseline() {
|
||||
local chart
|
||||
local chart_label
|
||||
local image
|
||||
local nodeport_matches
|
||||
local service_json
|
||||
|
||||
kubectl get node "$TARGET_NODE" >/dev/null
|
||||
|
||||
chart="$(
|
||||
kubectl --namespace kube-system get helmchart.helm.cattle.io traefik \
|
||||
--output=jsonpath='{.spec.chart}'
|
||||
)"
|
||||
[[ "$chart" == "$EXPECTED_K3S_CHART" ]] || \
|
||||
fail "unexpected packaged Traefik chart: ${chart}"
|
||||
|
||||
chart_label="$(
|
||||
kubectl --namespace kube-system get deployment traefik \
|
||||
--output=jsonpath='{.metadata.labels.helm\.sh/chart}'
|
||||
)"
|
||||
[[ "$chart_label" == "$EXPECTED_CHART_LABEL" ]] || \
|
||||
fail "unexpected live Traefik chart label: ${chart_label}"
|
||||
|
||||
image="$(
|
||||
kubectl --namespace kube-system get deployment traefik --output=json |
|
||||
jq --raw-output '
|
||||
.spec.template.spec.containers[]
|
||||
| select(.name == "traefik")
|
||||
| .image
|
||||
'
|
||||
)"
|
||||
[[ "$image" == "$EXPECTED_TRAEFIK_IMAGE" ]] || \
|
||||
fail "unexpected live Traefik image: ${image}"
|
||||
|
||||
[[ -r "$K3S_NODEPORT_CONFIG" ]] || \
|
||||
fail "cannot read the k3s nodeport-addresses drop-in: ${K3S_NODEPORT_CONFIG}"
|
||||
rg --quiet --fixed-strings --line-regexp -- \
|
||||
' - "nodeport-addresses=127.0.0.0/8"' "$K3S_NODEPORT_CONFIG" || \
|
||||
fail "k3s nodeport-addresses is not pinned to 127.0.0.0/8"
|
||||
|
||||
nodeport_matches="$(
|
||||
rg --no-heading --line-number -- 'nodeport-addresses[=:]' \
|
||||
/etc/rancher/k3s/config.yaml \
|
||||
/etc/rancher/k3s/config.yaml.d 2>/dev/null || true
|
||||
)"
|
||||
[[ "$(wc -l <<<"$nodeport_matches" | tr -d '[:space:]')" == "1" ]] || \
|
||||
fail "nodeport-addresses must have exactly one k3s configuration owner"
|
||||
rg --quiet --fixed-strings -- "$K3S_NODEPORT_CONFIG" <<<"$nodeport_matches" || \
|
||||
fail "nodeport-addresses is owned by an unexpected k3s configuration file"
|
||||
|
||||
service_json="$(
|
||||
kubectl --namespace kube-system get service traefik --output=json
|
||||
)"
|
||||
jq --exit-status '
|
||||
.spec.type == "NodePort"
|
||||
and .spec.externalTrafficPolicy == "Cluster"
|
||||
and (.spec.ports | length) == 2
|
||||
and any(.spec.ports[];
|
||||
.name == "web"
|
||||
and .port == 80
|
||||
and .nodePort == 30080
|
||||
and .protocol == "TCP")
|
||||
and any(.spec.ports[];
|
||||
.name == "websecure"
|
||||
and .port == 443
|
||||
and .nodePort == 30443
|
||||
and .protocol == "TCP")
|
||||
' >/dev/null <<<"$service_json" || \
|
||||
fail "Traefik Service no longer matches the 80/30080 and 443/30443 boundary"
|
||||
}
|
||||
|
||||
assert_health_body() {
|
||||
local body="$1"
|
||||
|
||||
jq --exit-status '
|
||||
.status == "pass"
|
||||
and (.checks["database:ping"] | length) > 0
|
||||
and all(.checks["database:ping"][]; .status == "pass")
|
||||
and (.checks["cache:ping"] | length) > 0
|
||||
and all(.checks["cache:ping"][]; .status == "pass")
|
||||
' >/dev/null <<<"$body" || fail "Gitea database/cache health is not pass"
|
||||
}
|
||||
|
||||
assert_nodeport_boundary_and_health() {
|
||||
local body
|
||||
local node_ip
|
||||
local port
|
||||
|
||||
node_ip="$(
|
||||
kubectl get node "$TARGET_NODE" --output=json |
|
||||
jq --raw-output '
|
||||
[.status.addresses[] | select(.type == "InternalIP") | .address]
|
||||
| if length == 1 then .[0] else empty end
|
||||
'
|
||||
)"
|
||||
[[ -n "$node_ip" && "$node_ip" != "127.0.0.1" ]] || \
|
||||
fail "could not resolve exactly one non-loopback node InternalIP"
|
||||
|
||||
for port in 30080 30443; do
|
||||
nc -z -w 3 127.0.0.1 "$port" >/dev/null 2>&1 || \
|
||||
fail "loopback NodePort is not listening: 127.0.0.1:${port}"
|
||||
if nc -z -w 3 "$node_ip" "$port" >/dev/null 2>&1; then
|
||||
fail "NodePort escaped the loopback boundary: ${node_ip}:${port}"
|
||||
fi
|
||||
done
|
||||
|
||||
body="$(
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--header "Host: ${GITEA_HOST}" \
|
||||
"$GITEA_HEALTH_URL"
|
||||
)"
|
||||
assert_health_body "$body"
|
||||
|
||||
body="$(
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
"$GITEA_HTTPS_HEALTH_URL"
|
||||
)"
|
||||
assert_health_body "$body"
|
||||
}
|
||||
|
||||
wait_for_nodeport_boundary_and_health() {
|
||||
local attempt
|
||||
|
||||
for ((attempt = 1; attempt <= 60; attempt++)); do
|
||||
if assert_nodeport_boundary_and_health >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# 마지막 검사는 오류 원인을 숨기지 않고 그대로 출력한다.
|
||||
assert_nodeport_boundary_and_health
|
||||
fail "Traefik NodePort boundary and Gitea health did not recover within 120 seconds"
|
||||
}
|
||||
|
||||
observe_host_nginx_client_host() {
|
||||
local http_code
|
||||
local logs
|
||||
local matched
|
||||
local nonce
|
||||
local observed
|
||||
local probe_path
|
||||
local router_count
|
||||
local since
|
||||
local -a client_hosts=()
|
||||
local attempt
|
||||
|
||||
since="$(date --utc '+%Y-%m-%dT%H:%M:%SZ')"
|
||||
nonce="$(date --utc '+%Y%m%dT%H%M%S')-${BASHPID}"
|
||||
probe_path="/api/healthz/traefik-source-${nonce}"
|
||||
|
||||
http_code="$(
|
||||
curl --silent --show-error \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
--output /dev/null \
|
||||
--write-out '%{http_code}' \
|
||||
"https://${GITEA_HOST}${probe_path}"
|
||||
)"
|
||||
[[ "$http_code" == "404" ]] || \
|
||||
fail "unique Host Nginx observation request returned HTTP ${http_code}, expected 404"
|
||||
|
||||
matched=""
|
||||
for ((attempt = 1; attempt <= 20; attempt++)); do
|
||||
logs="$(
|
||||
kubectl --namespace kube-system logs deployment/traefik \
|
||||
--since-time "$since"
|
||||
)"
|
||||
matched="$(
|
||||
jq --raw-input --compact-output --arg path "$probe_path" '
|
||||
fromjson?
|
||||
| select(.RequestPath == $path)
|
||||
' <<<"$logs"
|
||||
)"
|
||||
[[ -n "$matched" ]] && break
|
||||
sleep 1
|
||||
done
|
||||
[[ -n "$matched" ]] || \
|
||||
fail "the unique request was not found in Traefik JSON access logs"
|
||||
|
||||
router_count="$(
|
||||
jq --slurp '
|
||||
[
|
||||
.[]
|
||||
| select(
|
||||
((.RouterName // "") | ascii_downcase | contains("gitea"))
|
||||
)
|
||||
]
|
||||
| length
|
||||
' <<<"$matched"
|
||||
)"
|
||||
[[ "$router_count" -ge 1 ]] || \
|
||||
fail "the observation log did not traverse a Gitea router"
|
||||
|
||||
mapfile -t client_hosts < <(
|
||||
jq --raw-output '
|
||||
select((.RouterName // "") | ascii_downcase | contains("gitea"))
|
||||
| .ClientHost // empty
|
||||
' <<<"$matched" |
|
||||
sort --unique
|
||||
)
|
||||
[[ "${#client_hosts[@]}" -eq 1 && -n "${client_hosts[0]}" ]] || \
|
||||
fail "expected one distinct Traefik ClientHost for the unique request"
|
||||
|
||||
observed="${client_hosts[0]}"
|
||||
host_to_exact_cidr "$observed" >/dev/null
|
||||
printf '%s\n' "$observed"
|
||||
}
|
||||
|
||||
assert_manifest_https() {
|
||||
local body
|
||||
local attempt
|
||||
|
||||
for ((attempt = 1; attempt <= 30; attempt++)); do
|
||||
body="$(
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
"$GITEA_MANIFEST_URL"
|
||||
)"
|
||||
if jq --exit-status --arg expected "https://${GITEA_HOST}/" '
|
||||
.start_url == $expected
|
||||
and all(.icons[]; (.src | startswith("https://")))
|
||||
' >/dev/null <<<"$body"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fail "Gitea site manifest did not stabilize on HTTPS URLs"
|
||||
}
|
||||
|
||||
assert_live_hcc_matches_overlay() {
|
||||
local overlay="$1"
|
||||
local render_file="$2"
|
||||
local actual
|
||||
local expected
|
||||
|
||||
render_overlay "$overlay" "$render_file"
|
||||
expected="$(manifest_values_content "$render_file")"
|
||||
actual="$(
|
||||
kubectl --namespace kube-system \
|
||||
get helmchartconfig.helm.cattle.io traefik --output=json |
|
||||
jq --raw-output '.spec.valuesContent'
|
||||
)"
|
||||
[[ "$actual" == "$expected" ]] || \
|
||||
fail "live Traefik HelmChartConfig does not match the expected overlay"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash infrastructure/networking/traefik/scripts/observe-client-host.sh
|
||||
|
||||
Sends one unique HTTPS request through Host Nginx and extracts the corresponding
|
||||
ClientHost from Traefik JSON access logs. It performs no cluster mutation.
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "$#" -eq 0 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
require_commands kubectl jq rg curl nc python3 awk sort date
|
||||
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-clienthost.XXXXXX")"
|
||||
cleanup() {
|
||||
case "$work_dir" in
|
||||
/tmp/platform-traefik-clienthost.*|"${TMPDIR:-/tmp}"/platform-traefik-clienthost.*)
|
||||
rm -rf -- "$work_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$work_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
bash "${SCRIPT_DIR}/validate.sh"
|
||||
assert_live_baseline
|
||||
assert_runtime observe
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/live-observe.yaml"
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
observed_client_host="$(observe_host_nginx_client_host)"
|
||||
trusted_proxy_cidr="$(host_to_exact_cidr "$observed_client_host")"
|
||||
|
||||
printf '\nTRAEFIK SOURCE OBSERVED\n'
|
||||
printf 'ClientHost: %s\n' "$observed_client_host"
|
||||
printf 'Minimum trusted CIDR: %s\n' "$trusted_proxy_cidr"
|
||||
printf 'Record that CIDR in:\n%s\n' "$TRUST_PATCH"
|
||||
printf 'Replace only the sentinel %s, then run apply-trust.sh with this ClientHost.\n' \
|
||||
"$SENTINEL_TRUSTED_PROXY_CIDR"
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash infrastructure/networking/traefik/scripts/rollback-to-observe.sh --execute
|
||||
|
||||
Removes forwarded-header trust while retaining JSON access logging.
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
require_commands kubectl jq rg curl nc python3 awk cmp find bash
|
||||
|
||||
bash "${SCRIPT_DIR}/validate.sh"
|
||||
assert_live_baseline
|
||||
kubectl --namespace kube-system \
|
||||
get helmchartconfig.helm.cattle.io traefik >/dev/null
|
||||
assert_nodeport_boundary_and_health
|
||||
|
||||
selected_context="$(current_context)"
|
||||
selected_api_server="$(current_api_server)"
|
||||
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
|
||||
"$selected_context" "$selected_api_server" "$TARGET_NODE"
|
||||
printf 'Type ROLLBACK %s OBSERVE to remove forwarded-header trust: ' \
|
||||
"$selected_context"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "ROLLBACK ${selected_context} OBSERVE" ]] || \
|
||||
fail "cancelled"
|
||||
|
||||
[[ "$(current_context)" == "$selected_context" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(current_api_server)" == "$selected_api_server" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
assert_live_baseline
|
||||
|
||||
kubectl apply --kustomize "$OBSERVE_OVERLAY"
|
||||
wait_for_runtime observe
|
||||
kubectl --namespace kube-system rollout status deployment/traefik --timeout=5m
|
||||
assert_runtime observe
|
||||
|
||||
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-rollback.XXXXXX")"
|
||||
cleanup() {
|
||||
case "$work_dir" in
|
||||
/tmp/platform-traefik-rollback.*|"${TMPDIR:-/tmp}"/platform-traefik-rollback.*)
|
||||
rm -rf -- "$work_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$work_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
assert_live_hcc_matches_overlay "$OBSERVE_OVERLAY" \
|
||||
"${work_dir}/post-rollback-observe.yaml"
|
||||
assert_live_baseline
|
||||
wait_for_nodeport_boundary_and_health
|
||||
|
||||
printf '\nROLLBACK COMPLETE\n'
|
||||
printf 'JSON access logging remains enabled.\n'
|
||||
printf 'forwardedHeaders trust is absent from both entrypoints.\n'
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
# shellcheck source=common.sh
|
||||
source "${SCRIPT_DIR}/common.sh"
|
||||
|
||||
assert_count() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
local expected="$3"
|
||||
local description="$4"
|
||||
local actual
|
||||
|
||||
actual="$(rg --count --no-filename -- "$pattern" "$file" || true)"
|
||||
actual="${actual:-0}"
|
||||
[[ "$actual" == "$expected" ]] || \
|
||||
fail "${description}: expected ${expected}, found ${actual}"
|
||||
}
|
||||
|
||||
require_commands kubectl rg awk cmp python3 find bash
|
||||
|
||||
render_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-traefik-render.XXXXXX")"
|
||||
cleanup() {
|
||||
case "$render_dir" in
|
||||
/tmp/platform-traefik-render.*|"${TMPDIR:-/tmp}"/platform-traefik-render.*)
|
||||
rm -rf -- "$render_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected directory: %s\n' \
|
||||
"$render_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
root_render="${render_dir}/root.yaml"
|
||||
baseline_render="${render_dir}/baseline.yaml"
|
||||
observe_render="${render_dir}/observe.yaml"
|
||||
trust_render="${render_dir}/trust.yaml"
|
||||
|
||||
render_overlay "$TRAEFIK_ROOT" "$root_render"
|
||||
render_overlay "$BASELINE_OVERLAY" "$baseline_render"
|
||||
render_overlay "$OBSERVE_OVERLAY" "$observe_render"
|
||||
render_overlay "$TRUST_OVERLAY" "$trust_render"
|
||||
|
||||
cmp --silent "$root_render" "$observe_render" || \
|
||||
fail "the Traefik root must render the observation phase"
|
||||
|
||||
for manifest in "$baseline_render" "$observe_render" "$trust_render"; do
|
||||
assert_count "$manifest" '^apiVersion: helm\.cattle\.io/v1$' 1 \
|
||||
"HelmChartConfig API version"
|
||||
assert_count "$manifest" '^kind: HelmChartConfig$' 1 \
|
||||
"HelmChartConfig kind"
|
||||
assert_count "$manifest" '^ name: traefik$' 1 \
|
||||
"HelmChartConfig name"
|
||||
assert_count "$manifest" '^ namespace: kube-system$' 1 \
|
||||
"HelmChartConfig namespace"
|
||||
assert_count "$manifest" '^ failurePolicy: abort$' 1 \
|
||||
"Helm failure policy must preserve the running release on upgrade failure"
|
||||
assert_count "$manifest" '^ deployment:$' 1 \
|
||||
"Traefik Deployment values root"
|
||||
assert_count "$manifest" '^ podAnnotations:$' 1 \
|
||||
"Traefik Pod annotations block"
|
||||
assert_count "$manifest" '^ prometheus\.io/path: /metrics$' 1 \
|
||||
"Traefik legacy scrape path preservation"
|
||||
assert_count "$manifest" '^ prometheus\.io/port: "9100"$' 1 \
|
||||
"Traefik legacy scrape port preservation"
|
||||
assert_count "$manifest" '^ prometheus\.io/scrape: "true"$' 1 \
|
||||
"Traefik legacy scrape enablement preservation"
|
||||
assert_count "$manifest" '[Ii]nsecure' 0 \
|
||||
"insecure forwarded-header mode"
|
||||
assert_count "$manifest" '^ service:$' 1 \
|
||||
"Traefik Service values root"
|
||||
assert_count "$manifest" '^ type: NodePort$' 1 \
|
||||
"loopback NodePort Service type preservation"
|
||||
assert_count "$manifest" '^ externalTrafficPolicy: Cluster$' 1 \
|
||||
"Traefik externalTrafficPolicy preservation"
|
||||
assert_count "$manifest" '^ nodePort: 30080$' 1 \
|
||||
"Traefik web NodePort preservation"
|
||||
assert_count "$manifest" '^ nodePort: 30443$' 1 \
|
||||
"Traefik websecure NodePort preservation"
|
||||
assert_count "$manifest" '^ metrics:$' 1 \
|
||||
"Traefik metrics values root"
|
||||
assert_count "$manifest" '^ prometheus:$' 1 \
|
||||
"Traefik Prometheus metrics block"
|
||||
assert_count "$manifest" '^ serviceMonitor:$' 1 \
|
||||
"Traefik ServiceMonitor block"
|
||||
assert_count "$manifest" '^ jobLabel: app\.kubernetes\.io/name$' 1 \
|
||||
"Traefik ServiceMonitor job label"
|
||||
assert_count "$manifest" '^ observability\.hyeonworks\.com/instance: home$' 1 \
|
||||
"Traefik ServiceMonitor selector label"
|
||||
assert_count "$manifest" '^ interval: 30s$' 1 \
|
||||
"Traefik ServiceMonitor interval"
|
||||
assert_count "$manifest" '^ scrapeTimeout: 10s$' 1 \
|
||||
"Traefik ServiceMonitor timeout"
|
||||
assert_count "$manifest" '^ enabled: true$' 2 \
|
||||
"Traefik metrics Service and ServiceMonitor enablement"
|
||||
assert_count "$manifest" '^[[:space:]]*(type|serviceType):[[:space:]]*LoadBalancer' 0 \
|
||||
"LoadBalancer exposure"
|
||||
done
|
||||
|
||||
assert_count "$baseline_render" '^[[:space:]]*logs:' 0 \
|
||||
"baseline access logs"
|
||||
assert_count "$baseline_render" '^[[:space:]]*forwardedHeaders:' 0 \
|
||||
"baseline forwarded-header trust"
|
||||
|
||||
for manifest in "$observe_render" "$trust_render"; do
|
||||
assert_count "$manifest" '^ enabled: true$' 1 \
|
||||
"access log enablement"
|
||||
assert_count "$manifest" '^ format: json$' 1 \
|
||||
"JSON access log format"
|
||||
assert_count "$manifest" '^ defaultmode: keep$' 1 \
|
||||
"access-log general-field policy"
|
||||
assert_count "$manifest" '^ defaultmode: drop$' 1 \
|
||||
"access-log header policy"
|
||||
done
|
||||
|
||||
assert_count "$observe_render" '^[[:space:]]*forwardedHeaders:' 0 \
|
||||
"observation-phase forwarded-header trust"
|
||||
assert_count "$observe_render" '^[[:space:]]*trustedIPs:' 0 \
|
||||
"observation-phase trusted IP list"
|
||||
|
||||
assert_count "$trust_render" '^ ports:$' 1 \
|
||||
"trust-phase ports values root"
|
||||
assert_count "$trust_render" '^ web:$' 1 \
|
||||
"trust-phase web entrypoint"
|
||||
assert_count "$trust_render" '^ forwardedHeaders:$' 1 \
|
||||
"trust-phase forwarded-header block"
|
||||
assert_count "$trust_render" '^ trustedIPs:$' 1 \
|
||||
"trust-phase trusted IP list"
|
||||
|
||||
trusted_cidr="$(source_trusted_proxy_cidr)"
|
||||
normalized_cidr="$(normalize_exact_host_cidr "$trusted_cidr")"
|
||||
[[ "$trusted_cidr" == "$normalized_cidr" ]] || \
|
||||
fail "trusted proxy CIDR must use canonical exact-host notation"
|
||||
assert_count "$trust_render" \
|
||||
"^[[:space:]]*-[[:space:]]*\"${trusted_cidr//./\\.}\"[[:space:]]*$" 1 \
|
||||
"rendered exact-host trusted proxy CIDR"
|
||||
|
||||
while IFS= read -r -d '' script_path; do
|
||||
bash -n "$script_path"
|
||||
done < <(
|
||||
find "$SCRIPT_DIR" -maxdepth 1 -type f -name '*.sh' -print0
|
||||
)
|
||||
|
||||
printf 'Traefik observation and trust overlays rendered successfully.\n'
|
||||
printf 'Access logs are JSON and request headers are dropped.\n'
|
||||
printf 'No insecure mode, websecure trust, or LoadBalancer exposure was found.\n'
|
||||
printf 'The existing NodePort 30080/30443 boundary is declared in both phases.\n'
|
||||
if [[ "$trusted_cidr" == "$SENTINEL_TRUSTED_PROXY_CIDR" ]]; then
|
||||
printf 'Trust overlay remains intentionally blocked by sentinel CIDR %s.\n' \
|
||||
"$SENTINEL_TRUSTED_PROXY_CIDR"
|
||||
else
|
||||
printf 'Trust overlay contains reviewed exact-host CIDR %s.\n' "$trusted_cidr"
|
||||
fi
|
||||
Reference in New Issue
Block a user