Initial server source import

This commit is contained in:
sashatrask
2026-09-30 20:30:56 +03:00
commit 170dd941b9
498 changed files with 261563 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
ARG BASE_IMAGE=truf-local:runtime
FROM ${BASE_IMAGE}
COPY --chown=10001:10001 --chmod=0600 payload/app/capacity_model.py /opt/truf/app/capacity_model.py
COPY --chown=10001:10001 --chmod=0600 payload/app/scanner_db.py /opt/truf/app/scanner_db.py
COPY --chown=10001:10001 --chmod=0600 payload/app/worker_assignment.py /opt/truf/app/worker_assignment.py
COPY --chown=10001:10001 --chmod=0600 payload/app/worker_api.py /opt/truf/app/worker_api.py
COPY --chown=10001:10001 --chmod=0600 payload/app/jsonl_projector.py /opt/truf/app/jsonl_projector.py
COPY --chown=10001:10001 --chmod=0600 payload/app/runtime_document.py /opt/truf/app/runtime_document.py
COPY --chown=10001:10001 --chmod=0600 payload/app/lifecycle_authority.py /opt/truf/app/lifecycle_authority.py
COPY --chown=10001:10001 --chmod=0600 payload/app/config.linux.yaml /opt/truf/app/config.linux.yaml
RUN python3 -I -S -B - <<'PY'
import ast
from pathlib import Path
import sys
root = Path('/opt/truf/app')
names = (
'capacity_model.py',
'scanner_db.py',
'worker_assignment.py',
'worker_api.py',
'jsonl_projector.py',
'runtime_document.py',
'lifecycle_authority.py',
)
for name in names:
ast.parse((root / name).read_text(encoding='utf-8'), filename=name)
sys.path.insert(0, str(root))
from capacity_model import ( # noqa: E402
MAX_RESULT_BUNDLE_BYTES,
REMOTE_ASSIGNMENT_BASELINE_BYTES,
REMOTE_ASSIGNMENT_MAX_ACTIVE,
)
assert (MAX_RESULT_BUNDLE_BYTES, REMOTE_ASSIGNMENT_BASELINE_BYTES, REMOTE_ASSIGNMENT_MAX_ACTIVE) == (
64 * 1024 * 1024,
2 * 1024 * 1024,
50,
)
PY
+481
View File
@@ -0,0 +1,481 @@
#!/bin/bash
set -Eeuo pipefail
umask 077
MODE="${1:-}"
STAGE="${2:-}"
if [[ "$MODE" != plan && "$MODE" != apply ]]; then
echo 'usage: deploy.sh plan|apply STAGE' >&2
exit 64
fi
if [[ ! "$STAGE" =~ ^/var/lib/truf-deploy/stage/capacity50\.[A-Za-z0-9]+$ ]] || [[ ! -d "$STAGE" ]]; then
echo 'invalid deployment stage' >&2
exit 64
fi
readonly RELEASE_ID='capacity50-20260930'
readonly EXPECTED_IMAGE='sha256:46f1cf1b92d1a7d93d06f690309d8c7eca45f64dc45ca310861c70fb419bb035'
readonly EXPECTED_CONFIG_SHA256='12bd9a60cc56c3d6cbad18435523e8229b0cd8fdccc2d73922ea7e580ce7441c'
readonly CANDIDATE_TAG="truf-local:runtime-${RELEASE_ID}"
readonly ROLLBACK_TAG="truf-local:runtime-pre-${RELEASE_ID}"
readonly ACTIVE_CONFIG='/etc/truf/runtime/config.yaml'
readonly ACTIVE_SECRETS='/etc/truf/runtime/secrets.yaml'
readonly SOURCE_ROOT='/opt/truf'
readonly HISTORY="/var/lib/truf-deploy/history/${RELEASE_ID}"
readonly TEST_USER='operator-trace-windows-20260925'
readonly TEST_USER_ORIGINAL_CAP='2'
readonly APP_FILES=(
capacity_model.py
scanner_db.py
worker_assignment.py
worker_api.py
jsonl_projector.py
runtime_document.py
lifecycle_authority.py
config.linux.yaml
)
readonly COMPOSE=(
docker compose
--project-name truf-docker
--project-directory /opt/truf
--env-file /etc/truf-edge/edge.env
--file /opt/truf/compose.yaml
--file /opt/truf/compose.shared-host.yaml
)
PHASE='preflight'
MUTATED=0
PHASE_A_HEALTHY=0
SOURCE_INSTALLED=0
USER_CAP_CHANGED=0
DEPLOY_SUCCEEDED=0
RESUME=0
log() {
printf '[%s] %s\n' "$RELEASE_ID" "$*"
}
runtime_id() {
"${COMPOSE[@]}" ps --quiet runtime
}
psql() {
local container
container="$(runtime_id)"
[[ -n "$container" ]] || return 1
docker exec "$container" /usr/lib/postgresql/16/bin/psql \
-h /run/truf-postgres -U truf -d truf -v ON_ERROR_STOP=1 -At "$@"
}
current_image() {
docker image inspect --format '{{.Id}}' truf-local:runtime
}
config_sha256() {
sha256sum "$ACTIVE_CONFIG" | cut -d' ' -f1
}
require_baseline() {
[[ "$(current_image)" == "$EXPECTED_IMAGE" ]] || {
echo 'runtime image identity changed' >&2
return 1
}
[[ "$(config_sha256)" == "$EXPECTED_CONFIG_SHA256" ]] || {
echo 'active config identity changed' >&2
return 1
}
local state
state="$(psql -F '|' -c \
"SELECT revision, discovery_paused::int, dispatch_paused::int, drain_state FROM runtime_operations_control WHERE id=1;")"
if ((RESUME)); then
[[ "$state" =~ ^[0-9]+\|0\|0\|(normal|drained)$ ]] || {
echo "resumed runtime control is neither open nor drained: $state" >&2
return 1
}
elif [[ ! "$state" =~ ^[0-9]+\|0\|0\|normal$ ]]; then
echo "runtime control is not open: $state" >&2
return 1
fi
local debt
debt="$(psql -F '|' -c \
"SELECT (SELECT count(*) FROM result_reservations WHERE assignment_kind='remote' AND remote_resolved_at IS NULL), bundle_items, bundle_bytes, projection_items, projection_bytes, keycheck_items, keycheck_bytes, quarantine_items, quarantine_bytes FROM pipeline_capacity WHERE id=1;")"
[[ "$debt" == '0|0|0|0|0|0|0|0|0' ]] || {
echo "pipeline is not reconciled: $debt" >&2
return 1
}
[[ "$(psql -c "SELECT active_assignment_cap FROM remote_worker_users WHERE user_key='${TEST_USER}' AND disabled_at IS NULL;")" == "$TEST_USER_ORIGINAL_CAP" ]] || {
echo 'temporary validation user identity changed' >&2
return 1
}
}
edge_value() {
local name="$1"
sed -n "s/^${name}=//p" /etc/truf-edge/edge.env
}
admin_material() {
local marker host page token revision
marker="$(edge_value TRUF_ADMIN_EDGE_MARKER)"
host="$(edge_value TRUF_EDGE_HOST)"
[[ "$marker" =~ ^[a-f0-9]{64}$ ]] || return 1
[[ "$host" =~ ^[A-Za-z0-9.-]+$ ]] || return 1
page="$(curl --fail --silent --show-error --max-time 20 \
--header "X-Truf-Admin-Edge: ${marker}" \
--header 'X-Truf-Admin-Operator: deploy-runtime' \
http://127.0.0.1:8766/admin-internal/)"
token="$(python3 -c \
'import re,sys; values=set(re.findall(r"name=\"csrf_token\" value=\"([^\"]+)\"",sys.stdin.read())); print(values.pop() if len(values)==1 else "")' \
<<<"$page")"
revision="$(python3 -c \
'import re,sys; values=set(re.findall(r"name=\"expected_revision\" value=\"([0-9]+)\"",sys.stdin.read())); print(values.pop() if len(values)==1 else "")' \
<<<"$page")"
[[ "$token" =~ ^[A-Za-z0-9_-]{32,128}$ && "$revision" =~ ^[0-9]+$ ]] || return 1
printf '%s|%s|%s|%s\n' "$marker" "$host" "$token" "$revision"
}
admin_post() {
local route="$1"
shift
local material marker host token revision operation
material="$(admin_material)"
IFS='|' read -r marker host token revision <<<"$material"
operation="$(cat /proc/sys/kernel/random/uuid)"
local arguments=(
--fail --silent --show-error --max-time 30
--request POST
--header "X-Truf-Admin-Edge: ${marker}"
--header 'X-Truf-Admin-Operator: deploy-runtime'
--header "Origin: https://${host}"
--header 'Content-Type: application/x-www-form-urlencoded'
--data-urlencode "csrf_token=${token}"
--data-urlencode "operation_id=${operation}"
)
if [[ "$route" == dispatch/* || "$route" == search/discovery/* ]]; then
arguments+=(--data-urlencode "expected_revision=${revision}")
fi
while (($#)); do
arguments+=(--data-urlencode "$1")
shift
done
curl "${arguments[@]}" "http://127.0.0.1:8766/admin-internal/${route}" >/dev/null
}
wait_for_drain() {
local deadline=$((SECONDS + 600)) state debt
while ((SECONDS < deadline)); do
state="$(psql -c "SELECT drain_state FROM runtime_operations_control WHERE id=1;")"
debt="$(psql -F '|' -c \
"SELECT (SELECT count(*) FROM result_reservations WHERE assignment_kind='remote' AND remote_resolved_at IS NULL), bundle_items, bundle_bytes, projection_items, projection_bytes, keycheck_items, keycheck_bytes, quarantine_items, quarantine_bytes FROM pipeline_capacity WHERE id=1;")"
if [[ "$state" == drained && "$debt" == '0|0|0|0|0|0|0|0|0' ]]; then
return 0
fi
sleep 2
done
echo 'runtime did not drain within 600 seconds' >&2
return 1
}
quiesce_pipeline_workers() {
local source deadline active container
for source in result-ingester jsonl-projector; do
admin_post supervisor/sources/stop "source_id=${source}"
done
container="$(runtime_id)"
[[ -n "$container" ]] || return 1
docker exec --interactive "$container" /usr/local/bin/python3 -I -S -B - \
<"$STAGE/release_stopped_pipeline_leases.py"
deadline=$((SECONDS + 120))
while ((SECONDS < deadline)); do
active="$(psql -c \
"SELECT count(*) FROM pipeline_leases WHERE state NOT IN ('released','failed');")"
if [[ "$active" == 0 ]]; then
return 0
fi
sleep 2
done
echo 'pipeline worker leases did not release within 120 seconds' >&2
return 1
}
install_config() {
local source="$1" temporary
temporary="/etc/truf/runtime/.config.yaml.${RELEASE_ID}.tmp"
install -o root -g root -m 0600 "$source" "$temporary"
chown 10001:10001 "$temporary"
mv -f "$temporary" "$ACTIVE_CONFIG"
}
stop_stack() {
"${COMPOSE[@]}" stop --timeout 30 edge
"${COMPOSE[@]}" stop --timeout 600 runtime
local container state
container="$("${COMPOSE[@]}" ps --all --quiet runtime)"
state="$(docker inspect --format '{{.State.Status}}|{{.State.ExitCode}}|{{.State.OOMKilled}}' "$container")"
[[ "$state" == 'exited|0|false' ]] || {
echo "runtime stop was not clean: $state" >&2
return 1
}
}
wait_runtime_health() {
local deadline=$((SECONDS + 420)) container state status
while ((SECONDS < deadline)); do
container="$("${COMPOSE[@]}" ps --all --quiet runtime)"
if [[ -n "$container" ]]; then
state="$(docker inspect --format '{{.State.Status}}' "$container")"
[[ "$state" != exited && "$state" != dead ]] || return 1
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container")"
if [[ "$status" == healthy ]] && docker exec "$container" \
/usr/local/bin/python3 -I -S -B /opt/truf/app/container_runtime.py \
health --config /data/config/config.yaml --require-worker-api >/dev/null; then
return 0
fi
[[ "$status" != unhealthy ]] || return 1
fi
sleep 3
done
echo 'runtime health timed out' >&2
return 1
}
start_stack() {
"${COMPOSE[@]}" up --detach --no-deps --no-build --pull never --force-recreate runtime
wait_runtime_health
"${COMPOSE[@]}" up --detach --no-deps --no-build --pull never --force-recreate edge
sleep 3
local edge_container edge_state host public_code
edge_container="$("${COMPOSE[@]}" ps --quiet edge)"
edge_state="$(docker inspect --format '{{.State.Status}}|{{.State.Running}}|{{.State.OOMKilled}}' "$edge_container")"
[[ "$edge_state" == 'running|true|false' ]] || return 1
host="$(edge_value TRUF_EDGE_HOST)"
public_code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
--max-time 20 "https://${host}/")"
[[ "$public_code" == 401 || "$public_code" == 404 ]] || {
echo "unexpected public edge response: $public_code" >&2
return 1
}
}
validate_candidate_config() {
local path="$1"
docker run --rm --network none --read-only --user 10001:10001 \
--cap-drop ALL --security-opt no-new-privileges:true \
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=16m,mode=1777 \
--volume "$path:/data/config/config.yaml:ro" \
--volume "$ACTIVE_SECRETS:/data/config/secrets.yaml:ro" \
--volume /etc/truf/worker-packages:/data/worker-packages:ro \
--entrypoint /usr/local/bin/python3 "$CANDIDATE_TAG" -I -S -B -c \
"import sys,sysconfig;sys.path.append(sysconfig.get_paths()['purelib']);sys.path.insert(0,'/opt/truf/app');from runtime_document_io import validate_managed_runtime_files;print(validate_managed_runtime_files('/data/config/config.yaml').config_sha256)" \
>/dev/null
}
install_sources() {
local name destination temporary
for name in "${APP_FILES[@]}"; do
destination="${SOURCE_ROOT}/app/${name}"
temporary="${destination}.${RELEASE_ID}.tmp"
install -o root -g root -m 0644 "$STAGE/payload/app/$name" "$temporary"
mv -f "$temporary" "$destination"
done
SOURCE_INSTALLED=1
}
restore_sources() {
local name
for name in "${APP_FILES[@]}"; do
if [[ -f "$HISTORY/source/$name" ]]; then
install -o root -g root -m 0644 "$HISTORY/source/$name" "${SOURCE_ROOT}/app/$name"
else
rm -f "${SOURCE_ROOT}/app/$name"
fi
done
}
restore_user_cap() {
if ((USER_CAP_CHANGED)); then
admin_post users/cap "user_key=${TEST_USER}" "active_assignment_cap=${TEST_USER_ORIGINAL_CAP}" || true
USER_CAP_CHANGED=0
fi
}
cancel_drain() {
local state
state="$(psql -c "SELECT drain_state FROM runtime_operations_control WHERE id=1;" 2>/dev/null || true)"
if [[ "$state" == draining || "$state" == drained ]]; then
admin_post dispatch/drain/cancel || return 1
fi
}
rollback() {
set +e
log "rollback from phase ${PHASE}"
restore_user_cap
stop_stack
if ((PHASE_A_HEALTHY)); then
docker image tag "$CANDIDATE_TAG" truf-local:runtime
install_config "$HISTORY/config.conservative.yaml"
else
docker image tag "$EXPECTED_IMAGE" truf-local:runtime
install_config "$HISTORY/config.original.yaml"
fi
((SOURCE_INSTALLED)) && restore_sources
if start_stack; then
cancel_drain
log 'rollback restored a healthy runtime'
else
log 'rollback could not prove health; runtime remains contained' >&2
fi
set -e
}
on_exit() {
local code=$?
trap - EXIT ERR INT TERM
if ((code != 0 && MUTATED && !DEPLOY_SUCCEEDED)); then
rollback
fi
exit "$code"
}
trap on_exit EXIT
exec 9>/run/lock/truf-runtime-deploy.lock
flock -n 9 || {
echo 'another runtime deployment is active' >&2
exit 1
}
if [[ "$MODE" == apply && -d "$HISTORY" ]] \
&& docker image inspect "$CANDIDATE_TAG" >/dev/null 2>&1 \
&& [[ "$(docker image inspect --format '{{.Id}}' "$ROLLBACK_TAG" 2>/dev/null || true)" == "$EXPECTED_IMAGE" ]]; then
RESUME=1
fi
require_baseline
available_kb="$(df -Pk /var/lib/docker | awk 'NR==2 {print $4}')"
[[ "$available_kb" =~ ^[0-9]+$ && "$available_kb" -ge 786432 ]] || {
echo 'less than 768 MiB is available for the derived image' >&2
exit 1
}
log "plan image=${EXPECTED_IMAGE#sha256:} config=${EXPECTED_CONFIG_SHA256} free_kib=${available_kb}"
if [[ "$MODE" == plan ]]; then
log 'plan passed; no runtime state changed'
exit 0
fi
if ((RESUME)); then
log 'resuming a verified pre-cutover release'
[[ "$(sha256sum "$HISTORY/config.original.yaml" | cut -d' ' -f1)" == "$EXPECTED_CONFIG_SHA256" ]] || exit 1
[[ -f "$HISTORY/config.conservative.yaml" && -f "$HISTORY/config.final.yaml" ]] || exit 1
validate_candidate_config "$HISTORY/config.conservative.yaml"
validate_candidate_config "$HISTORY/config.final.yaml"
if [[ "$(psql -c "SELECT drain_state FROM runtime_operations_control WHERE id=1;")" == normal ]]; then
admin_post dispatch/drain/start
MUTATED=1
wait_for_drain
else
MUTATED=1
fi
else
install -d -o root -g root -m 0700 /var/lib/truf-deploy /var/lib/truf-deploy/history
if [[ -e "$HISTORY" ]]; then
echo 'release history already exists' >&2
exit 1
fi
install -d -o root -g root -m 0700 "$HISTORY" "$HISTORY/source"
install -o root -g root -m 0600 "$ACTIVE_CONFIG" "$HISTORY/config.original.yaml"
for name in "${APP_FILES[@]}"; do
if [[ -f "${SOURCE_ROOT}/app/$name" ]]; then
install -o root -g root -m 0600 "${SOURCE_ROOT}/app/$name" "$HISTORY/source/$name"
fi
done
python3 "$STAGE/render_config.py" --input "$ACTIVE_CONFIG" \
--output "$HISTORY/config.conservative.yaml" --mode conservative \
--expected-sha256 "$EXPECTED_CONFIG_SHA256" >/dev/null
python3 "$STAGE/render_config.py" --input "$ACTIVE_CONFIG" \
--output "$HISTORY/config.final.yaml" --mode final \
--expected-sha256 "$EXPECTED_CONFIG_SHA256" >/dev/null
chown 10001:10001 "$HISTORY/config.conservative.yaml" "$HISTORY/config.final.yaml"
chmod 0600 "$HISTORY/config.conservative.yaml" "$HISTORY/config.final.yaml"
if docker image inspect "$CANDIDATE_TAG" >/dev/null 2>&1; then
echo 'candidate image tag already exists' >&2
exit 1
fi
PHASE='candidate-build'
docker build --network none --build-arg BASE_IMAGE=truf-local:runtime \
--file "$STAGE/Dockerfile" --tag "$CANDIDATE_TAG" "$STAGE"
[[ "$(current_image)" == "$EXPECTED_IMAGE" ]] || {
echo 'runtime tag changed during candidate build' >&2
exit 1
}
validate_candidate_config "$HISTORY/config.conservative.yaml"
validate_candidate_config "$HISTORY/config.final.yaml"
docker image tag "$EXPECTED_IMAGE" "$ROLLBACK_TAG"
PHASE='drain'
admin_post dispatch/drain/start
MUTATED=1
wait_for_drain
fi
PHASE='conservative-cutover'
quiesce_pipeline_workers
stop_stack
install_config "$HISTORY/config.conservative.yaml"
docker image tag "$CANDIDATE_TAG" truf-local:runtime
start_stack
schema_state="$(psql -F '|' -c \
"SELECT (SELECT count(*) FROM information_schema.columns WHERE table_name='result_reservations' AND column_name='reserved_bundle_bytes'), (SELECT count(*) FROM runtime_schema_migrations WHERE version='20260930_33_remote_assignment_capacity');")"
[[ "$schema_state" == '1|1' ]] || {
echo "capacity migration was not applied: $schema_state" >&2
exit 1
}
PHASE_A_HEALTHY=1
PHASE='capacity50-cutover'
quiesce_pipeline_workers
stop_stack
install_config "$HISTORY/config.final.yaml"
start_stack
final_values="$(psql -F '|' -c \
"SELECT bundle_items, bundle_bytes, projection_items, projection_bytes, keycheck_items, keycheck_bytes, quarantine_items, quarantine_bytes FROM pipeline_capacity WHERE id=1;")"
[[ "$final_values" == '0|0|0|0|0|0|0|0' ]] || {
echo "post-deploy capacity is not reconciled: $final_values" >&2
exit 1
}
PHASE='temporary-user-cap-validation'
admin_post users/cap "user_key=${TEST_USER}" 'active_assignment_cap=50'
USER_CAP_CHANGED=1
[[ "$(psql -c "SELECT active_assignment_cap FROM remote_worker_users WHERE user_key='${TEST_USER}';")" == 50 ]] || exit 1
restore_user_cap
[[ "$(psql -c "SELECT active_assignment_cap FROM remote_worker_users WHERE user_key='${TEST_USER}';")" == "$TEST_USER_ORIGINAL_CAP" ]] || exit 1
PHASE='source-install'
install_sources
for name in "${APP_FILES[@]}"; do
cmp -s "$STAGE/payload/app/$name" "${SOURCE_ROOT}/app/$name" || exit 1
done
PHASE='resume'
cancel_drain
post_control="$(psql -F '|' -c \
"SELECT discovery_paused::int, dispatch_paused::int, drain_state FROM runtime_operations_control WHERE id=1;")"
[[ "$post_control" == '0|0|normal' ]] || {
echo "runtime control did not resume: $post_control" >&2
exit 1
}
cat >"$HISTORY/result.txt" <<EOF
release=${RELEASE_ID}
runtime_image=$(current_image)
config_sha256=$(config_sha256)
schema=${schema_state}
capacity=${final_values}
control=${post_control}
EOF
chmod 0600 "$HISTORY/result.txt"
DEPLOY_SUCCEEDED=1
log "applied image=$(current_image) config=$(config_sha256)"
@@ -0,0 +1,74 @@
import glob
import os
import sys
import sysconfig
for path in glob.glob('/proc/[0-9]*/cmdline'):
try:
command = open(path, 'rb').read().replace(b'\0', b' ')
except (FileNotFoundError, PermissionError, ProcessLookupError):
continue
if b'/opt/truf/app/result_ingester.py' in command or b'/opt/truf/app/jsonl_projector.py' in command:
raise SystemExit('a pipeline worker process is still active')
sys.path.append(sysconfig.get_paths()['purelib'])
sys.path.insert(0, '/opt/truf/app')
import psycopg
from psycopg.rows import dict_row
from db_backend import DatabaseConnection
from scanner_db import (
PIPELINE_ADVISORY_CLASS,
PIPELINE_ADVISORY_OBJECTS,
ScannerDB,
)
connection = psycopg.connect(
dbname='truf',
user='truf',
host='/run/truf-postgres',
row_factory=dict_row,
options='-c search_path=public -c statement_timeout=30000 -c lock_timeout=5000',
)
database = ScannerDB(enabled=False)
database.conn = DatabaseConnection('postgres', connection, application_schema='public')
released = 0
try:
rows = database.conn.execute(
"SELECT worker_name, generation, lease_token FROM pipeline_leases "
"WHERE state NOT IN ('released','failed') ORDER BY worker_name"
).fetchall()
expected = {'result_ingester', 'jsonl_projector'}
if not {row['worker_name'] for row in rows} <= expected:
raise RuntimeError('an unexpected pipeline lease is active')
for row in rows:
name = row['worker_name']
advisory = PIPELINE_ADVISORY_OBJECTS[name]
locked = database.conn.execute(
'SELECT pg_try_advisory_lock(?, ?) AS acquired',
(PIPELINE_ADVISORY_CLASS, advisory),
).fetchone()
if not locked or not locked['acquired']:
raise RuntimeError('a stopped pipeline worker still owns its advisory lock')
database._pipeline_advisory_held.add(name)
if not database.release_pipeline_lease(
name,
row['generation'],
row['lease_token'],
state='released',
error='deployment_quiesce',
):
raise RuntimeError('pipeline lease identity changed during release')
released += 1
remaining = database.conn.execute(
"SELECT count(*) AS count FROM pipeline_leases "
"WHERE state NOT IN ('released','failed')"
).fetchone()['count']
if remaining:
raise RuntimeError('pipeline lease reconciliation is incomplete')
print(f'released_pipeline_leases={released}')
finally:
database.close()
+73
View File
@@ -0,0 +1,73 @@
import argparse
import hashlib
from pathlib import Path
VALUES = {
'conservative': {
'remote_assignment_reserve_bytes': 64 * 1024 * 1024,
'remote_assignment_max_active': 1,
'keycheck_queue_max_items': 8192,
'keycheck_queue_max_bytes': 64 * 1024 * 1024,
},
'final': {
'remote_assignment_reserve_bytes': 2 * 1024 * 1024,
'remote_assignment_max_active': 50,
'keycheck_queue_max_items': 131072,
'keycheck_queue_max_bytes': 128 * 1024 * 1024,
},
}
def render(payload, mode):
text = payload.decode('utf-8', errors='strict')
if '\r' in text:
raise ValueError('configuration must use LF line endings')
lines = text.splitlines(keepends=True)
values = VALUES[mode]
hard_limit = ' result_bundle_max_event_bytes: 67108864\n'
if lines.count(hard_limit) != 1:
raise ValueError('unexpected hard result limit')
for field in ('remote_assignment_reserve_bytes', 'remote_assignment_max_active'):
if any(line.startswith(f' {field}:') for line in lines):
raise ValueError(f'active configuration already contains {field}')
index = lines.index(hard_limit) + 1
lines[index:index] = [
f" remote_assignment_reserve_bytes: {values['remote_assignment_reserve_bytes']}\n",
f" remote_assignment_max_active: {values['remote_assignment_max_active']}\n",
]
replacements = {
' keycheck_queue_max_items: 8192\n': (
f" keycheck_queue_max_items: {values['keycheck_queue_max_items']}\n"
),
' keycheck_queue_max_bytes: 67108864\n': (
f" keycheck_queue_max_bytes: {values['keycheck_queue_max_bytes']}\n"
),
}
for before, after in replacements.items():
if lines.count(before) != 1:
raise ValueError(f'unexpected active configuration field: {before.strip()}')
lines[lines.index(before)] = after
return ''.join(lines).encode('utf-8')
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--input', required=True)
parser.add_argument('--output', required=True)
parser.add_argument('--mode', choices=tuple(VALUES), required=True)
parser.add_argument('--expected-sha256', required=True)
args = parser.parse_args()
source = Path(args.input)
destination = Path(args.output)
payload = source.read_bytes()
if hashlib.sha256(payload).hexdigest() != args.expected_sha256:
raise SystemExit('active configuration identity changed')
rendered = render(payload, args.mode)
destination.write_bytes(rendered)
print(hashlib.sha256(rendered).hexdigest())
if __name__ == '__main__':
main()
+133
View File
@@ -0,0 +1,133 @@
{
admin unix//run/caddy-admin.sock
auto_https disable_redirects
skip_install_trust
}
(admin_security) {
header {
Cache-Control "no-store"
Pragma "no-cache"
Referrer-Policy "same-origin"
Content-Security-Policy "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
X-Content-Type-Options "nosniff"
}
}
(admin_gate) {
import {$TRUF_ADMIN_DENYLIST_FILE:/etc/caddy/denylist/admin-denylist.caddy}
basic_auth bcrypt "truf-admin" {
{$TRUF_ADMIN_USER} {$TRUF_ADMIN_PASSWORD_HASH}
}
}
{$TRUF_EDGE_HOST} {
import {$TRUF_EDGE_TLS_INCLUDE:/etc/caddy/tls/automatic.caddy}
import admin_security
header Strict-Transport-Security "max-age=63072000; includeSubDomains"
log routine_access {
output discard
}
log admin_auth_failures {
no_hostname
output file {$TRUF_ADMIN_AUTH_LOG_FILE:/var/log/caddy/admin-auth-failures.json} {
roll_size 8MiB
roll_keep 10
roll_keep_for 240h
}
format filter {
request delete
bytes_read delete
user_id delete
duration delete
size delete
resp_headers delete
wrap json
}
}
@worker path /api/v1/worker/*
handle @worker {
reverse_proxy 127.0.0.1:8766 {
header_up -X-Truf-Admin-Edge
header_up -X-Truf-Admin-Operator
header_up -Forwarded
header_up -X-Real-IP
}
}
@admin_root path /{$TRUF_ADMIN_PREFIX}
handle @admin_root {
route {
import admin_security
import admin_gate
redir * /{$TRUF_ADMIN_PREFIX}/ 308
}
}
@admin path /{$TRUF_ADMIN_PREFIX}/*
handle @admin {
route {
import admin_security
request_header -X-Truf-Admin-Edge
request_header -X-Truf-Admin-Operator
import admin_gate
uri strip_prefix /{$TRUF_ADMIN_PREFIX}
uri path_regexp ^ /admin-internal
reverse_proxy 127.0.0.1:8766 {
header_up -Authorization
header_up X-Truf-Admin-Edge {$TRUF_ADMIN_EDGE_MARKER}
header_up X-Truf-Admin-Operator {http.auth.user.id}
header_up -Forwarded
header_up -X-Real-IP
header_down -Strict-Transport-Security
}
}
}
handle {
respond "" 404
}
handle_errors {
@bad_admin_credentials {
path /{$TRUF_ADMIN_PREFIX} /{$TRUF_ADMIN_PREFIX}/*
header Authorization *
expression {err.status_code} == 401
}
handle @bad_admin_credentials {
route {
import admin_security
log_name admin_auth_failures
log_append event admin_auth_failure
log_append remote_ip {http.request.remote.host}
header WWW-Authenticate "Basic realm=\"truf-admin\""
respond "" 401
}
}
@admin_unauthorized {
path /{$TRUF_ADMIN_PREFIX} /{$TRUF_ADMIN_PREFIX}/*
expression {err.status_code} == 401
}
handle @admin_unauthorized {
route {
import admin_security
header WWW-Authenticate "Basic realm=\"truf-admin\""
respond "" 401
}
}
@admin_error path /{$TRUF_ADMIN_PREFIX} /{$TRUF_ADMIN_PREFIX}/*
handle @admin_error {
import admin_security
respond "" {err.status_code}
}
handle {
respond "" {err.status_code}
}
}
}
+147
View File
@@ -0,0 +1,147 @@
{
admin unix//run/caddy-admin.sock
auto_https off
skip_install_trust
servers {
trusted_proxies static 127.0.0.1/32 ::1/128
trusted_proxies_strict
client_ip_headers X-Forwarded-For
}
}
(admin_security) {
header {
Cache-Control "no-store"
Pragma "no-cache"
Referrer-Policy "same-origin"
Content-Security-Policy "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
X-Content-Type-Options "nosniff"
}
}
(admin_gate) {
import {$TRUF_ADMIN_DENYLIST_FILE:/etc/caddy/denylist/admin-denylist.caddy}
basic_auth bcrypt "truf-admin" {
{$TRUF_ADMIN_USER} {$TRUF_ADMIN_PASSWORD_HASH}
}
}
http://:18766 {
bind 127.0.0.1
log routine_access {
output discard
}
log admin_auth_failures {
no_hostname
output file {$TRUF_ADMIN_AUTH_LOG_FILE:/var/log/caddy/admin-auth-failures.json} {
roll_size 8MiB
roll_keep 10
roll_keep_for 240h
}
format filter {
request delete
bytes_read delete
user_id delete
duration delete
size delete
resp_headers delete
wrap json
}
}
route {
@invalid_ingress not header X-Truf-Shared-Ingress {$TRUF_SHARED_INGRESS_MARKER}
respond @invalid_ingress "" 403
request_header -X-Truf-Shared-Ingress
import admin_security
header Strict-Transport-Security "max-age=63072000; includeSubDomains"
@worker path /api/v1/worker/*
handle @worker {
reverse_proxy 127.0.0.1:8766 {
header_up -X-Truf-Admin-Edge
header_up -X-Truf-Admin-Operator
header_up -X-Truf-Shared-Ingress
header_up -Forwarded
header_up -X-Real-IP
}
}
@admin_root path /{$TRUF_ADMIN_PREFIX}
handle @admin_root {
route {
import admin_security
import admin_gate
redir * /{$TRUF_ADMIN_PREFIX}/ 308
}
}
@admin path /{$TRUF_ADMIN_PREFIX}/*
handle @admin {
route {
import admin_security
request_header -X-Truf-Admin-Edge
request_header -X-Truf-Admin-Operator
import admin_gate
uri strip_prefix /{$TRUF_ADMIN_PREFIX}
uri path_regexp ^ /admin-internal
reverse_proxy 127.0.0.1:8766 {
header_up -Authorization
header_up X-Truf-Admin-Edge {$TRUF_ADMIN_EDGE_MARKER}
header_up X-Truf-Admin-Operator {http.auth.user.id}
header_up -X-Truf-Shared-Ingress
header_up -Forwarded
header_up -X-Real-IP
header_down -Strict-Transport-Security
}
}
}
handle {
respond "" 404
}
}
handle_errors {
@bad_admin_credentials {
path /{$TRUF_ADMIN_PREFIX} /{$TRUF_ADMIN_PREFIX}/*
header Authorization *
expression {err.status_code} == 401
}
handle @bad_admin_credentials {
route {
import admin_security
log_name admin_auth_failures
log_append event admin_auth_failure
log_append remote_ip {http.request.client_ip}
header WWW-Authenticate "Basic realm=\"truf-admin\""
respond "" 401
}
}
@admin_unauthorized {
path /{$TRUF_ADMIN_PREFIX} /{$TRUF_ADMIN_PREFIX}/*
expression {err.status_code} == 401
}
handle @admin_unauthorized {
route {
import admin_security
header WWW-Authenticate "Basic realm=\"truf-admin\""
respond "" 401
}
}
@admin_error path /{$TRUF_ADMIN_PREFIX} /{$TRUF_ADMIN_PREFIX}/*
handle @admin_error {
import admin_security
respond "" {err.status_code}
}
handle {
respond "" {err.status_code}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d AS edge
USER 0:0
RUN setcap -r /usr/bin/caddy \
&& install -d -o 10001 -g 10001 -m 0700 /data /config /var/log/caddy /etc/caddy/denylist /etc/caddy/tls
COPY --chown=0:0 --chmod=0444 deploy/edge/Caddyfile /etc/caddy/Caddyfile
COPY --chown=0:0 --chmod=0444 deploy/edge/Caddyfile.shared-host /etc/caddy/Caddyfile.shared-host
COPY --chown=0:0 --chmod=0444 deploy/edge/admin-denylist.caddy /etc/caddy/denylist/admin-denylist.caddy
COPY --chown=0:0 --chmod=0444 deploy/edge/automatic-tls.caddy /etc/caddy/tls/automatic.caddy
COPY --chown=0:0 --chmod=0555 deploy/edge/entrypoint.sh /usr/local/bin/truf-edge-entrypoint
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/truf-edge-entrypoint"]
CMD []
FROM edge AS edge-e2e
USER 0:0
RUN chmod 0644 /etc/caddy/Caddyfile \
&& sed -i 's#^[[:space:]]*admin unix//run/caddy-admin.sock$#\tadmin 127.0.0.1:2019#' /etc/caddy/Caddyfile \
&& chmod 0444 /etc/caddy/Caddyfile \
&& grep -Fx ' admin 127.0.0.1:2019' /etc/caddy/Caddyfile >/dev/null
USER 10001:10001
+253
View File
@@ -0,0 +1,253 @@
# Production edge deployment
This opt-in deployment keeps PostgreSQL, supervisor control, the standalone dashboard,
the Worker API, and its typed admin backend on the runtime container's loopback. The
root-owned deployment profile selects one of two exact Caddy topologies. Both keep the
private Caddy admin API on an unpublished Unix socket and preserve the same Worker API,
admin authentication, operator attribution, denylist, and header contract.
## Deployment profiles
If `/etc/truf/deployment-profile` is absent, `standalone-edge-v1` is selected. The
standalone profile publishes runtime TCP 443 and gives the managed edge only
`NET_BIND_SERVICE`.
For a host whose existing root-owned Caddy must remain the sole owner of ports 80/443,
install the shared profile before running the host-agent installer:
```sh
printf '%s\n' shared-host-edge-v1 | sudo install -m 0444 -o root -g root /dev/stdin /etc/truf/deployment-profile
```
`shared-host-edge-v1` runs the runtime in the host network namespace with no Docker
published ports. The managed Truf edge shares that namespace, has no capabilities, and
binds plain HTTP only at `127.0.0.1:18766`. The existing host Caddy imports the fixed
route-only `deploy/edge/host-caddy-shared.caddy` snippet inside the reviewed public site.
Install that import before any catch-all handler. It handles only `/api/v1/worker/*` and
the exact random admin prefix; it does not define a listener, TLS policy, global option,
or route for another application. The host agent never restarts or reconfigures host
Caddy or X-UI.
Profile changes are maintenance operations: stop the host agent first, require no active
apply or failed hold, install the exact root-owned mode-0444 value, validate the selected
Compose projection and host-Caddy configuration, then restart the agent. Never expose
profile selection through the admin or host-agent request.
### Choosing a topology
Use `standalone-edge-v1` on a dedicated host where the managed edge can own public TCP
443. The request path is:
```text
Internet -> managed Caddy :443 -> private runtime :8766
```
Use `shared-host-edge-v1` only when an existing root-owned Caddy must remain the sole
owner of public ports and TLS. The request path is:
```text
Internet -> host Caddy :443 -> 127.0.0.1:18766 -> managed Caddy -> private runtime :8766
```
Shared-host mode adds a one-time integration boundary, not a second public edge. The
operator installs the fixed route snippet, places its import before every catch-all,
supplies the independent ingress marker, and validates the complete host Caddy
configuration. After that bootstrap, runtime restart and document apply use the same
host-agent lifecycle as standalone mode. The host agent never owns the host Caddy
configuration or service lifecycle.
These are the only supported production topologies. Nginx, Traefik, an arbitrary Caddy
layout, or an ad-hoc Compose override is not equivalent to either profile. Add and test a
new exact deployment profile instead of translating private headers approximately. The
host agent validates the selected fixed Compose projection and rejects metadata drift.
The shared-host projection currently carries the constrained-host runtime limits declared
in `compose.shared-host.yaml`. A materially different CPU or memory envelope also requires
a reviewed profile change; do not hide it in an unvalidated local override.
### End-to-end host bootstrap
The repository provides fixed deployment components, not a universal VPS installer,
Ansible role, public image registry, or infrastructure module. Bootstrap a new host from
one reviewed release checkout as follows:
1. Install the reviewed Linux, Docker Engine and Compose plugin, systemd, Python 3, and
fail2ban prerequisites; provision DNS and the selected TLS ownership boundary.
2. Install the release checkout root-owned at `/opt/truf` and choose exactly one deployment
profile before installing the host agent.
3. In shared-host mode, install the fixed host-Caddy import, validate the complete host
configuration, and prove an unavailable loopback edge cannot fall through to another
application.
4. Create the protected edge directories, denylist state, and mode-0600 edge environment
described below. Generate independent admin, edge, and shared-ingress values rather
than copying values from another host.
5. Install and validate the fixed host agent. Its first install seeds an absent active
config from `app/config.linux.yaml` and an absent secrets document as an empty mapping;
repeat installation never replaces active documents.
6. Install every trusted worker-package manifest referenced by the runtime config beneath
`/etc/truf/worker-packages` with the exact ownership and mode described below.
7. Review the private active config and secrets, then build `runtime` and `edge` from the
same checkout with the exact base and selected profile Compose files.
8. Start the stack, explicitly enable Worker API and admin only after their private
configuration is complete, and verify PostgreSQL, runtime, edge, HTTPS, admin, Worker
API, host-agent, and unrelated host applications.
Initial host bootstrap is therefore intentionally more manual than later operation.
Normal config apply, restart, rollback, status, and audit are performed through the typed
control plane and fixed host agent after this trust boundary is established.
## Host agent and fixed runtime paths
Install the root-owned checkout at `/opt/truf`. Before invoking the host-agent installer,
prepare the edge state below and create the complete protected environment file that its
fixed combined-Compose validation consumes.
UID/GID 10001 is the numeric edge identity. The denylist directory is mounted, rather than
its file, so atomic replacement remains visible in the container.
```sh
sudo install -d -o 10001 -g 10001 -m 0700 /var/log/truf-edge
sudo install -d -o root -g 10001 -m 2750 /etc/truf-edge/denylist
sudo install -m 0640 -o root -g 10001 deploy/edge/admin-denylist.caddy /etc/truf-edge/denylist/admin-denylist.caddy
sudo install -d -o root -g root -m 0700 /var/lib/truf-edge
sudo install -m 0750 -o root -g root deploy/fail2ban/truf_caddy_admin_denylist.py /usr/local/sbin/truf-caddy-admin-denylist
```
Create `/etc/truf-edge/edge.env` as root with mode 0600. Generate a new admin segment
with `openssl rand -hex 32`. It must be exactly 64 lowercase hex characters (256 random
bits). Generate the bcrypt value interactively with the pinned edge image's
`caddy hash-password` command; never put the plaintext password in a command, file, or
Compose variable.
```dotenv
TRUF_EDGE_HOST=edge.example.net
TRUF_ADMIN_PREFIX=replace_with_64_lowercase_hex_characters
TRUF_ADMIN_USER=operator
TRUF_ADMIN_PASSWORD_HASH='$2a$14$replace_with_a_real_caddy_bcrypt_hash'
TRUF_ADMIN_EDGE_MARKER=replace_with_a_second_independent_64_character_hex_secret
TRUF_SHARED_INGRESS_MARKER=replace_with_a_third_independent_64_character_hex_secret
TRUF_EDGE_AUTH_LOG_DIR=/var/log/truf-edge
TRUF_EDGE_DENYLIST_DIR=/etc/truf-edge/denylist
```
`TRUF_SHARED_INGRESS_MARKER` is required only by `shared-host-edge-v1`. Host Caddy strips
any inbound transit/private headers, injects this marker and its observed client address,
and proxies to loopback. The managed edge rejects a missing marker before trusting that
address and removes the marker before proxying to the application.
Now install and validate the fixed host agent. The installer creates the fixed candidate,
result, PostgreSQL socket, and active-document paths and enables
`/run/truf/host-agent.sock`; Compose refuses to create missing bind sources.
```sh
sudo /usr/bin/python3 -I -S -B /opt/truf/deploy/host-agent/truf_host_agent_install.py install
sudo /usr/bin/python3 -I -S -B /opt/truf/deploy/host-agent/truf_host_agent_install.py validate
```
The active `/etc/truf/runtime/config.yaml` and `secrets.yaml` are UID/GID 10001 mode 0600
documents and are never overwritten by repeat installation. Any package manifest referenced
by the config must be installed beneath `/etc/truf/worker-packages` as a root-owned,
root:root mode 0644 regular file before validation. The runtime maps that immutable authority
read-only at `/data/worker-packages`; do not place manifests in the private active-document
directory.
Automatic TLS remains the default. A deployment that must use operator-provided
certificates can mount a root-owned, non-link `*.caddy` file under `/etc/caddy/tls` and
set `TRUF_EDGE_TLS_INCLUDE` to that absolute container path in a reviewed Compose
override. The include should contain only the site's `tls CERT KEY` directive. Never use
the repository's localhost test certificate or key in a deployment.
The normal `compose.yaml` remains private and unchanged. Confirm the host-agent socket is
active and rerun installer validation immediately before starting production edge. Always
supply the base file, the exact selected profile file, and the protected environment file.
For standalone:
```sh
docker compose --env-file /etc/truf-edge/edge.env -f compose.yaml -f compose.edge.yaml build runtime edge
docker compose --env-file /etc/truf-edge/edge.env -f compose.yaml -f compose.edge.yaml up -d
```
For shared host:
```sh
docker compose --env-file /etc/truf-edge/edge.env -f compose.yaml -f compose.shared-host.yaml build runtime edge
docker compose --env-file /etc/truf-edge/edge.env -f compose.yaml -f compose.shared-host.yaml up -d
```
Before starting shared host, validate the complete existing host Caddy configuration with
the snippet import in place. A matching request must fail at that Truf route if the
loopback edge is unavailable; it must never fall through to X-UI or another upstream.
Provisioning creates the private `/data/managed-files` namespace in the named data volume.
Each configured writable root must be a reviewed immediate child such as
`/data/managed-files/exports`, created with UID/GID 10001 and mode 0700 while the runtime is
stopped. Arbitrary host bind paths are not managed-file roots.
Worker admission remains disabled by `app/config.linux.yaml`. Configure the private
runtime config's worker sources, compatibility profiles, and hashed device credentials
before explicitly enabling `supervisor.worker_api.enabled`. The edge does not enable it.
The typed admin backend is disabled independently under `supervisor.worker_api.admin`.
Set its exact `origin` to `https://TRUF_EDGE_HOST`, set `edge_marker` to the same independent
256-bit value as `TRUF_ADMIN_EDGE_MARKER`, then explicitly enable it. Both authenticated
surfaces share the private runtime loopback port 8766. Caddy strips any inbound
`X-Truf-Admin-Edge` and `X-Truf-Admin-Operator`, sets the configured marker and the
authenticated Basic-auth username only after authentication, and rewrites the public
random prefix to the private `/admin-internal` backend path. The backend accepts the
operator identity only together with the private marker.
Worker API requests receive neither private admin header.
Build and client bootstrap instructions for Windows and Linux remote workers are in
`docs/remote-worker-operations.md`. Worker executables and images must be produced from a
reviewed release checkout; operators must not assemble Python, Git, TruffleHog, detector
policy, or dependencies manually on each worker.
## Fail2ban
Install the host files under their conventional names and enable fail2ban plus the expiry
timer. The jail counts only redacted `admin_auth_failure` JSON records. An initial Basic
challenge without credentials, Worker API authentication failures, and unrelated 404s do
not enter that log. The action changes only the matcher imported inside the secret admin
route; it does not create firewall rules and therefore does not block workers sharing an IP.
```sh
sudo install -m 0644 deploy/fail2ban/filter.d-truf-admin-auth.conf /etc/fail2ban/filter.d/truf-admin-auth.conf
sudo install -m 0644 deploy/fail2ban/jail.d-truf-admin-auth.local /etc/fail2ban/jail.d/truf-admin-auth.local
sudo install -m 0644 deploy/fail2ban/action.d-truf-caddy-admin-denylist.conf /etc/fail2ban/action.d/truf-caddy-admin-denylist.conf
sudo install -m 0644 deploy/fail2ban/fail2ban.d-truf-persistence.local /etc/fail2ban/fail2ban.d/truf-persistence.local
sudo install -m 0644 deploy/systemd/truf-caddy-admin-denylist-expire.service /etc/systemd/system/
sudo install -m 0644 deploy/systemd/truf-caddy-admin-denylist-expire.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now fail2ban truf-caddy-admin-denylist-expire.timer
sudo fail2ban-client status truf-admin-auth
```
Fail2ban persists jail state in `/var/lib/fail2ban/fail2ban.sqlite3`. The updater persists
canonical IPs and expiry timestamps in private
`/var/lib/truf-edge/admin-denylist.json`. It validates the complete Caddyfile in the running
edge container, reloads it through the private admin endpoint, and restores/reloads the
previous state if a command fails. Reload uses Caddy's private `/run/caddy-admin.sock` inside the edge
container. The socket is not mounted or published. Shared mode renders denylist matchers
against the marker-authenticated client address; standalone mode uses the direct peer.
## SSH recovery
Use fail2ban's normal unban first so its database and Caddy agree:
```sh
sudo fail2ban-client set truf-admin-auth unbanip 203.0.113.10
sudo /usr/local/sbin/truf-caddy-admin-denylist status
sudo /usr/local/sbin/truf-caddy-admin-denylist expire
```
If fail2ban is unavailable, run the updater's explicit unban over SSH:
```sh
sudo /usr/local/sbin/truf-caddy-admin-denylist unban 203.0.113.10
```
For recovery from a damaged generated snippet, stop the expiry timer and fail2ban, restore
`deploy/edge/admin-denylist.caddy` to `/etc/truf-edge/denylist/admin-denylist.caddy`, then
run Caddy validation and reload through the private Unix admin socket only after validation
succeeds. Reconcile each
remaining address with the updater before re-enabling the services. Do not use a global
firewall ban as a shortcut.
+1
View File
@@ -0,0 +1 @@
# Managed by truf-caddy-admin-denylist. Admin-route import only.
+1
View File
@@ -0,0 +1 @@
# Empty by design: Caddy's automatic TLS remains the production default.
+71
View File
@@ -0,0 +1,71 @@
#!/bin/sh
set -eu
fail() {
echo "edge configuration rejected: $1" >&2
exit 64
}
host=${TRUF_EDGE_HOST:-}
prefix=${TRUF_ADMIN_PREFIX:-}
user=${TRUF_ADMIN_USER:-}
password_hash=${TRUF_ADMIN_PASSWORD_HASH:-}
edge_marker=${TRUF_ADMIN_EDGE_MARKER:-}
edge_mode=${TRUF_EDGE_MODE:-standalone-edge-v1}
ingress_marker=${TRUF_SHARED_INGRESS_MARKER:-}
tls_include=${TRUF_EDGE_TLS_INCLUDE:-}
case "$edge_mode" in
standalone-edge-v1) caddyfile=/etc/caddy/Caddyfile ;;
shared-host-edge-v1)
caddyfile=/etc/caddy/Caddyfile.shared-host
[ "${#ingress_marker}" -eq 64 ] || fail "TRUF_SHARED_INGRESS_MARKER must encode 256 random bits"
printf '%s' "$ingress_marker" | grep -Eq '^[0-9a-f]{64}$' \
|| fail "TRUF_SHARED_INGRESS_MARKER must encode 256 random bits"
;;
*) fail "TRUF_EDGE_MODE is unsupported" ;;
esac
if [ "$host" = localhost ]; then
[ -n "$tls_include" ] || fail "localhost requires an explicit static TLS include"
else
case "$host" in
''|*://*|*/*|*:*|.*|*..*|*.) fail "TRUF_EDGE_HOST must be one DNS hostname" ;;
esac
printf '%s' "$host" | awk -F. '
length($0) > 253 || NF < 2 { exit 1 }
{ for (i = 1; i <= NF; i++) if (length($i) > 63 || $i !~ /^[A-Za-z0-9-]+$/ || $i ~ /^-/ || $i ~ /-$/) exit 1 }
' \
|| fail "TRUF_EDGE_HOST must be one DNS hostname"
fi
if [ -n "$tls_include" ]; then
case "$tls_include" in
/etc/caddy/tls/*.caddy) ;;
*) fail "TRUF_EDGE_TLS_INCLUDE must be an absolute Caddy TLS include" ;;
esac
[ -f "$tls_include" ] && [ ! -L "$tls_include" ] \
|| fail "TRUF_EDGE_TLS_INCLUDE must be a regular non-link file"
fi
[ "${#prefix}" -eq 64 ] || fail "TRUF_ADMIN_PREFIX must encode 256 random bits"
printf '%s' "$prefix" | grep -Eq '^[0-9a-f]{64}$' \
|| fail "TRUF_ADMIN_PREFIX must encode 256 random bits"
printf '%s' "$user" | grep -Eq '^[A-Za-z0-9_.-]{1,64}$' \
|| fail "TRUF_ADMIN_USER has an unsupported form"
printf '%s' "$password_hash" | grep -Eq '^\$2[aby]\$(0[4-9]|[12][0-9]|3[01])\$[./A-Za-z0-9]{53}$' \
|| fail "TRUF_ADMIN_PASSWORD_HASH must be a supported bcrypt hash"
[ "${#edge_marker}" -eq 64 ] || fail "TRUF_ADMIN_EDGE_MARKER must encode 256 random bits"
printf '%s' "$edge_marker" | grep -Eq '^[0-9a-f]{64}$' \
|| fail "TRUF_ADMIN_EDGE_MARKER must encode 256 random bits"
[ -f /etc/caddy/denylist/admin-denylist.caddy ] \
|| fail "the managed admin denylist snippet is missing"
[ ! -L /etc/caddy/denylist/admin-denylist.caddy ] \
|| fail "the managed admin denylist snippet must not be a link"
[ -d /var/log/caddy ] && [ -w /var/log/caddy ] \
|| fail "the authentication log directory is not writable"
umask 077
exec caddy run --config "$caddyfile" --adapter caddyfile
+26
View File
@@ -0,0 +1,26 @@
# Import this route-only snippet inside the reviewed public site block.
@truf_worker path /api/v1/worker/*
handle @truf_worker {
reverse_proxy 127.0.0.1:18766 {
header_up X-Truf-Shared-Ingress {$TRUF_SHARED_INGRESS_MARKER}
header_up -X-Truf-Admin-Edge
header_up -X-Truf-Admin-Operator
header_up -Forwarded
header_up -X-Real-IP
header_up -X-Forwarded-For
header_up X-Forwarded-For {http.request.remote.host}
}
}
@truf_admin path /{$TRUF_ADMIN_PREFIX} /{$TRUF_ADMIN_PREFIX}/*
handle @truf_admin {
reverse_proxy 127.0.0.1:18766 {
header_up X-Truf-Shared-Ingress {$TRUF_SHARED_INGRESS_MARKER}
header_up -X-Truf-Admin-Edge
header_up -X-Truf-Admin-Operator
header_up -Forwarded
header_up -X-Real-IP
header_up -X-Forwarded-For
header_up X-Forwarded-For {http.request.remote.host}
}
}
+61
View File
@@ -0,0 +1,61 @@
FROM caddy:2.10.2-alpine@sha256:4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d AS caddy-edge-e2e
FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 AS fail2ban-edge-e2e
COPY --from=caddy-edge-e2e --chown=0:0 --chmod=0444 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN <<'SH'
set -eu
rm -f /etc/apt/sources.list /etc/apt/sources.list.d/debian.sources
printf '%s\n' \
'Types: deb' \
'URIs: http://snapshot.debian.org/archive/debian/20260914T000000Z/' \
'Suites: bookworm bookworm-updates' \
'Components: main' \
'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \
'Check-Valid-Until: no' \
'' \
'Types: deb' \
'URIs: http://snapshot.debian.org/archive/debian-security/20260914T000000Z/' \
'Suites: bookworm-security' \
'Components: main' \
'Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg' \
'Check-Valid-Until: no' \
> /etc/apt/sources.list.d/debian.sources
printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
chmod 0755 /usr/sbin/policy-rc.d
export DEBIAN_FRONTEND=noninteractive
apt-get -o Acquire::Retries=3 -o Acquire::https::Timeout=30 -o APT::Update::Error-Mode=any update
apt-get install -y --no-install-recommends fail2ban=1.0.2-2
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* /var/log/apt/*
find /etc/fail2ban/jail.d -type f -delete
install -d -o 0 -g 0 -m 0755 \
/etc/caddy /etc/caddy/denylist /etc/caddy/tls /etc/fail2ban/action.d /etc/fail2ban/fail2ban.d \
/etc/fail2ban/filter.d /etc/fail2ban/jail.d /etc/truf-edge/denylist \
/run/fail2ban /var/lib/fail2ban /var/lib/truf-edge /var/log/caddy /var/log/truf-edge
SH
COPY --from=caddy-edge-e2e --chown=0:0 --chmod=0555 /usr/bin/caddy /usr/bin/caddy
RUN cp /usr/bin/caddy /usr/bin/caddy-edge-e2e \
&& rm /usr/bin/caddy \
&& mv /usr/bin/caddy-edge-e2e /usr/bin/caddy \
&& chmod 0555 /usr/bin/caddy
COPY --chown=0:0 --chmod=0444 deploy/edge/Caddyfile /etc/caddy/Caddyfile
RUN chmod 0644 /etc/caddy/Caddyfile \
&& sed -i 's/^[[:space:]]*admin off$/\tadmin 127.0.0.1:2019/' /etc/caddy/Caddyfile \
&& chmod 0444 /etc/caddy/Caddyfile \
&& grep -Fx ' admin 127.0.0.1:2019' /etc/caddy/Caddyfile >/dev/null
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/filter.d-truf-admin-auth.conf /etc/fail2ban/filter.d/truf-admin-auth.conf
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/jail.d-truf-admin-auth.local /etc/fail2ban/jail.d/truf-admin-auth.local
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/action.d-truf-caddy-admin-denylist.conf /etc/fail2ban/action.d/truf-caddy-admin-denylist.conf
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/fail2ban.d-truf-persistence.local /etc/fail2ban/fail2ban.d/truf-persistence.local
COPY --chown=0:0 --chmod=0444 deploy/fail2ban/fail2ban.d-edge-e2e.local /etc/fail2ban/fail2ban.d/edge-e2e.local
COPY --chown=0:0 --chmod=0555 deploy/fail2ban/truf_caddy_admin_denylist.py /usr/local/sbin/truf-caddy-admin-denylist
COPY --chown=0:0 --chmod=0555 deploy/fail2ban/edge_e2e_docker_shim.py /usr/local/bin/docker
RUN fail2ban-server --version 2>&1 | grep -F 'v1.0.2' >/dev/null \
&& test "$(find /etc/fail2ban/jail.d -type f | wc -l)" -eq 1
USER 0:0
ENTRYPOINT ["/usr/bin/fail2ban-server", "-f", "-x"]
CMD []
@@ -0,0 +1,4 @@
[Definition]
actionstart = /usr/local/sbin/truf-caddy-admin-denylist expire
actionban = /usr/local/sbin/truf-caddy-admin-denylist ban '<ip>'
actionunban = /usr/local/sbin/truf-caddy-admin-denylist unban '<ip>'
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/python3
"""Constrain production denylist reload commands to the colocated E2E Caddy."""
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
ENV_FILE = Path("/etc/truf-edge/edge.env")
AUDIT_PATH = Path("/var/lib/truf-edge/edge-e2e-reload.audit")
VALIDATION_ERROR_PATH = Path("/var/lib/truf-edge/edge-e2e-validation.error")
COMPOSE_PREFIX = (
"compose", "--ansi", "never", "--env-file", str(ENV_FILE),
"--project-directory", "/opt/truf",
"--file", "/opt/truf/compose.yaml",
"--file", "/opt/truf/compose.edge.yaml",
)
VALIDATE_COMMAND = COMPOSE_PREFIX + (
"exec", "-T", "edge", "caddy", "validate",
"--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile",
)
RELOAD_COMMAND = COMPOSE_PREFIX + ("kill", "--signal", "SIGUSR1", "edge")
REQUIRED_ENV = {
"TRUF_EDGE_HOST",
"TRUF_EDGE_TLS_INCLUDE",
"TRUF_ADMIN_PREFIX",
"TRUF_ADMIN_USER",
"TRUF_ADMIN_PASSWORD_HASH",
"TRUF_ADMIN_EDGE_MARKER",
}
def classify_command(arguments):
command = tuple(arguments)
if command == VALIDATE_COMMAND:
return "validate"
if command == RELOAD_COMMAND:
return "reload"
raise ValueError("unsupported command")
def audit(operation, result):
payload = f"{operation}:{result}\n".encode("ascii")
flags = (
os.O_WRONLY | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_BINARY", 0)
)
descriptor = os.open(AUDIT_PATH, flags, 0o600)
try:
details = os.fstat(descriptor)
if not stat.S_ISREG(details.st_mode) or details.st_size + len(payload) > 4096:
raise ValueError("invalid audit file")
os.write(descriptor, payload)
finally:
os.close(descriptor)
def record_validation_error(content, environment):
if len(content) > 65536:
content = b"caddy validation error exceeded evidence bound\n"
text = content.decode("utf-8", errors="replace")
for value in environment.values():
if value:
text = text.replace(value, "[redacted]")
text = re.sub(r"\$2[aby]\$(?:0[4-9]|[12][0-9]|3[01])\$[./A-Za-z0-9]{53}", "[redacted]", text)
text = re.sub(r"(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])", "[redacted]", text)
payload = text.encode("utf-8", errors="replace")[:4096]
descriptor = os.open(
VALIDATION_ERROR_PATH,
os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_BINARY", 0),
0o600,
)
try:
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
raise ValueError("invalid validation evidence file")
os.write(descriptor, payload)
finally:
os.close(descriptor)
def load_environment(path=ENV_FILE):
details = path.lstat()
if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode) or details.st_size > 8192:
raise ValueError("invalid environment file")
values = {}
for raw_line in path.read_text(encoding="ascii").splitlines():
if not raw_line or raw_line.startswith("#"):
continue
name, separator, value = raw_line.partition("=")
if not separator or name not in REQUIRED_ENV or name in values or "\x00" in value:
raise ValueError("invalid environment entry")
values[name] = value
if set(values) != REQUIRED_ENV:
raise ValueError("incomplete environment")
if (
values["TRUF_EDGE_HOST"] != "localhost"
or values["TRUF_EDGE_TLS_INCLUDE"] != "/etc/caddy/tls/static-tls.caddy"
or not re.fullmatch(r"[0-9a-f]{64}", values["TRUF_ADMIN_PREFIX"])
or not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", values["TRUF_ADMIN_USER"])
or not re.fullmatch(r"\$2[aby]\$(?:0[4-9]|[12][0-9]|3[01])\$[./A-Za-z0-9]{53}", values["TRUF_ADMIN_PASSWORD_HASH"])
or not re.fullmatch(r"[0-9a-f]{64}", values["TRUF_ADMIN_EDGE_MARKER"])
):
raise ValueError("unsupported environment")
return {
**values,
"HOME": "/tmp",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"PATH": "/usr/bin:/bin",
}
def validate():
try:
environment = load_environment()
except Exception:
audit("environment", 64)
raise
try:
completed = subprocess.run(
(
"/usr/bin/caddy", "validate", "--config", "/etc/caddy/Caddyfile",
"--adapter", "caddyfile",
),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
env=environment,
timeout=30,
check=False,
)
except Exception:
audit("caddy-exec", 64)
raise
if completed.returncode:
record_validation_error(completed.stderr, environment)
return completed.returncode
def reload_caddy():
try:
command = Path("/proc/1/cmdline").read_bytes()
except Exception:
audit("reload-proc", 64)
raise
if (
len(command) > 4096
or command.rstrip(b"\0").split(b"\0")
not in (
[b"caddy", b"run", b"--config", b"/etc/caddy/Caddyfile", b"--adapter", b"caddyfile"],
[b"/usr/bin/caddy", b"run", b"--config", b"/etc/caddy/Caddyfile", b"--adapter", b"caddyfile"],
)
):
audit("reload-identity", 64)
raise ValueError("unexpected pid namespace")
completed = subprocess.run(
(
"/usr/bin/caddy", "reload", "--config", "/etc/caddy/Caddyfile",
"--adapter", "caddyfile", "--address", "127.0.0.1:2019",
),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=load_environment(),
timeout=30,
check=False,
)
return completed.returncode
def main(argv=None):
try:
operation = classify_command((argv or sys.argv)[1:])
result = validate() if operation == "validate" else reload_caddy()
except Exception:
if "operation" in locals():
try:
audit(operation, 64)
except Exception:
pass
return 64
try:
audit(operation, result)
except Exception:
return 64
return result
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,5 @@
[Definition]
loglevel = INFO
logtarget = STDOUT
socket = /run/fail2ban/fail2ban.sock
pidfile = /run/fail2ban/fail2ban.pid
@@ -0,0 +1,3 @@
[Definition]
dbfile = /var/lib/fail2ban/fail2ban.sqlite3
dbpurgeage = 7d
@@ -0,0 +1,4 @@
[Definition]
failregex = ^(?=.{1,1024}$)(?=.*"event"\s*:\s*"admin_auth_failure")(?=.*"status"\s*:\s*401)(?=.*"remote_ip"\s*:\s*"<HOST>")(?!.*"(?:request|uri|headers|authorization|password|token|prefix)"\s*:).*\s*$
ignoreregex =
datepattern = "ts":{EPOCH}
@@ -0,0 +1,9 @@
[truf-admin-auth]
enabled = true
filter = truf-admin-auth
logpath = /var/log/truf-edge/admin-auth-failures.json
backend = auto
maxretry = 2
findtime = 10m
bantime = 24h
action = truf-caddy-admin-denylist
@@ -0,0 +1,438 @@
#!/usr/bin/env python3
"""Maintain the Caddy admin-only IP denylist with durable expiry state."""
import argparse
from contextlib import contextmanager
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile
import time
BAN_SECONDS = 24 * 60 * 60
MAX_BANS = 4096
MAX_FILE_BYTES = 512 * 1024
STATE_VERSION = 1
EMPTY_SNIPPET = "# Managed by truf-caddy-admin-denylist. Admin-route import only.\n"
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
PROFILE_PATH = Path("/etc/truf/deployment-profile")
STANDALONE_PROFILE = "standalone-edge-v1"
SHARED_HOST_PROFILE = "shared-host-edge-v1"
class UpdateError(RuntimeError):
pass
class CommandFailure(UpdateError):
pass
class RollbackFailure(UpdateError):
pass
def canonical_ip(value):
text = str(value or "")
if not text or len(text) > 64 or "%" in text or any(char.isspace() for char in text):
raise UpdateError("invalid IP address")
try:
address = ipaddress.ip_address(text)
except ValueError as exc:
raise UpdateError("invalid IP address") from exc
if address.is_unspecified or address.is_multicast:
raise UpdateError("unsupported IP address")
return address.compressed.lower()
def render_snippet(bans, matcher="remote_ip"):
if matcher not in {"remote_ip", "client_ip"}:
raise UpdateError("unsupported denylist matcher")
addresses = sorted(
(ipaddress.ip_address(address) for address in bans),
key=lambda address: (address.version, int(address)),
)
if not addresses:
return EMPTY_SNIPPET.encode("ascii")
lines = [EMPTY_SNIPPET.rstrip("\n")]
for offset in range(0, len(addresses), 64):
name = f"truf_admin_denied_{offset // 64:04d}"
values = " ".join(address.compressed.lower() for address in addresses[offset:offset + 64])
lines.append(f"@{name} {matcher} {values}")
lines.append(f'respond @{name} "" 403')
return ("\n".join(lines) + "\n").encode("ascii")
def _digest(content):
return hashlib.sha256(content).hexdigest()
def _check_parent(path):
parent = path.parent
details = parent.lstat()
if not stat.S_ISDIR(details.st_mode) or stat.S_ISLNK(details.st_mode):
raise UpdateError("managed parent must be a real directory")
if os.name == "posix" and stat.S_IMODE(details.st_mode) & 0o002:
raise UpdateError("managed parent must not be world-writable")
def _read_optional(path):
_check_parent(path)
try:
details = path.lstat()
except FileNotFoundError:
return None
if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
raise UpdateError("managed path must be a regular file")
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags)
try:
current = os.fstat(descriptor)
if not stat.S_ISREG(current.st_mode) or current.st_size > MAX_FILE_BYTES:
raise UpdateError("managed file is invalid or too large")
chunks = []
remaining = MAX_FILE_BYTES + 1
while remaining:
chunk = os.read(descriptor, min(65536, remaining))
if not chunk:
break
chunks.append(chunk)
remaining -= len(chunk)
content = b"".join(chunks)
if len(content) > MAX_FILE_BYTES:
raise UpdateError("managed file is too large")
return content
finally:
os.close(descriptor)
def _sync_parent(parent):
if os.name != "posix":
return
descriptor = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _atomic_write(path, content, mode):
_check_parent(path)
if len(content) > MAX_FILE_BYTES:
raise UpdateError("managed content is too large")
try:
existing = path.lstat()
except FileNotFoundError:
existing = None
if existing is not None and (not stat.S_ISREG(existing.st_mode) or stat.S_ISLNK(existing.st_mode)):
raise UpdateError("managed path must be a regular file")
descriptor, temporary = tempfile.mkstemp(prefix=".truf-denylist-", dir=path.parent)
temporary_path = Path(temporary)
try:
if hasattr(os, "fchmod"):
os.fchmod(descriptor, mode)
else:
os.chmod(temporary_path, mode)
with os.fdopen(descriptor, "wb", closefd=True) as handle:
descriptor = -1
handle.write(content)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary_path, path)
_sync_parent(path.parent)
finally:
if descriptor >= 0:
os.close(descriptor)
try:
temporary_path.unlink()
except FileNotFoundError:
pass
def _restore(path, content, mode):
if content is not None:
_atomic_write(path, content, mode)
return
try:
details = path.lstat()
except FileNotFoundError:
return
if not stat.S_ISREG(details.st_mode) or stat.S_ISLNK(details.st_mode):
raise UpdateError("managed path changed during rollback")
path.unlink()
_sync_parent(path.parent)
@contextmanager
def _exclusive_lock(path):
_check_parent(path)
flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags, 0o600)
try:
details = os.fstat(descriptor)
if not stat.S_ISREG(details.st_mode):
raise UpdateError("lock path must be a regular file")
if os.name == "posix":
import fcntl
fcntl.flock(descriptor, fcntl.LOCK_EX)
yield
finally:
os.close(descriptor)
def _load_state(content):
if content is None:
return {"version": STATE_VERSION, "bans": {}, "applied_sha256": ""}
try:
value = json.loads(content.decode("ascii"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise UpdateError("denylist state is not valid JSON") from exc
if not isinstance(value, dict) or set(value) != {"version", "bans", "applied_sha256"}:
raise UpdateError("denylist state has an invalid schema")
if value["version"] != STATE_VERSION or not isinstance(value["bans"], dict):
raise UpdateError("denylist state has an unsupported version")
if len(value["bans"]) > MAX_BANS:
raise UpdateError("denylist state exceeds its entry bound")
applied = value["applied_sha256"]
if not isinstance(applied, str) or (applied and not SHA256_RE.fullmatch(applied)):
raise UpdateError("denylist state has an invalid applied digest")
bans = {}
for address, expires_at in value["bans"].items():
canonical = canonical_ip(address)
if canonical != address or isinstance(expires_at, bool) or not isinstance(expires_at, int):
raise UpdateError("denylist state has a noncanonical entry")
if expires_at <= 0 or expires_at > 253402300799:
raise UpdateError("denylist state has an invalid expiry")
bans[canonical] = expires_at
return {"version": STATE_VERSION, "bans": bans, "applied_sha256": applied}
def _encode_state(state):
return (json.dumps(state, sort_keys=True, separators=(",", ":")) + "\n").encode("ascii")
def _subprocess_runner(command):
environment = {
"HOME": "/root",
"LANG": "C.UTF-8",
"LC_ALL": "C.UTF-8",
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
}
try:
completed = subprocess.run(
command,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=environment,
timeout=45,
check=False,
)
except (OSError, subprocess.SubprocessError):
return False
return completed.returncode == 0
class DenylistUpdater:
def __init__(
self,
state_path,
snippet_path,
project_directory="/opt/truf",
env_file="/etc/truf-edge/edge.env",
profile=None,
runner=None,
clock=None,
):
self.state_path = Path(state_path)
self.snippet_path = Path(snippet_path)
self.lock_path = self.state_path.with_suffix(self.state_path.suffix + ".lock")
if profile is None:
try:
profile = PROFILE_PATH.read_text(encoding="ascii").strip()
except FileNotFoundError:
profile = STANDALONE_PROFILE
except (OSError, UnicodeError):
raise UpdateError("deployment profile is unreadable") from None
if profile not in {STANDALONE_PROFILE, SHARED_HOST_PROFILE}:
raise UpdateError("deployment profile is unsupported")
compose_file = (
"compose.shared-host.yaml"
if profile == SHARED_HOST_PROFILE else "compose.edge.yaml"
)
caddyfile = (
"/etc/caddy/Caddyfile.shared-host"
if profile == SHARED_HOST_PROFILE else "/etc/caddy/Caddyfile"
)
self.matcher = "client_ip" if profile == SHARED_HOST_PROFILE else "remote_ip"
compose = (
"docker", "compose", "--ansi", "never", "--env-file", str(env_file),
"--project-directory", str(project_directory),
"--file", str(Path(project_directory) / "compose.yaml"),
"--file", str(Path(project_directory) / compose_file),
)
self.validate_command = compose + (
"exec", "-T", "edge", "caddy", "validate",
"--config", caddyfile, "--adapter", "caddyfile",
)
self.reload_command = compose + (
"exec", "-T", "edge", "caddy", "reload",
"--config", caddyfile, "--adapter", "caddyfile",
"--address", "unix//run/caddy-admin.sock",
)
self.runner = runner or _subprocess_runner
self.clock = clock or time.time
def _run(self, command, phase):
try:
succeeded = self.runner(command)
except Exception as exc:
raise CommandFailure(f"{phase} command failed") from exc
if not succeeded:
raise CommandFailure(f"{phase} command failed")
def update(self, operation, address=None):
if operation not in {"ban", "unban", "expire", "status"}:
raise UpdateError("unsupported operation")
canonical = canonical_ip(address) if operation in {"ban", "unban"} else None
now = int(self.clock())
if now <= 0:
raise UpdateError("system clock is invalid")
with _exclusive_lock(self.lock_path):
old_state_content = _read_optional(self.state_path)
old_snippet_content = _read_optional(self.snippet_path)
state = _load_state(old_state_content)
bans = {
ip: expires_at for ip, expires_at in state["bans"].items()
if expires_at > now
}
expired = len(state["bans"]) - len(bans)
if operation == "ban":
if canonical not in bans and len(bans) >= MAX_BANS:
raise UpdateError("denylist entry bound reached")
bans[canonical] = max(bans.get(canonical, 0), now + BAN_SECONDS)
elif operation == "unban":
bans.pop(canonical, None)
desired_snippet = render_snippet(bans, self.matcher)
desired_digest = _digest(desired_snippet)
pending_state = {
"version": STATE_VERSION,
"bans": bans,
"applied_sha256": state["applied_sha256"],
}
pending_content = _encode_state(pending_state)
needs_reload = (
old_snippet_content != desired_snippet
or state["applied_sha256"] != desired_digest
)
needs_state_write = old_state_content != pending_content
if needs_reload:
reload_attempted = False
try:
_atomic_write(self.state_path, pending_content, 0o600)
_atomic_write(self.snippet_path, desired_snippet, 0o640)
self._run(self.validate_command, "validation")
reload_attempted = True
self._run(self.reload_command, "reload")
pending_state["applied_sha256"] = desired_digest
_atomic_write(self.state_path, _encode_state(pending_state), 0o600)
except Exception as original:
try:
_restore(self.state_path, old_state_content, 0o600)
_restore(self.snippet_path, old_snippet_content, 0o640)
if reload_attempted:
self._run(self.validate_command, "rollback validation")
self._run(self.reload_command, "rollback reload")
except Exception as rollback:
raise RollbackFailure("denylist rollback failed") from rollback
if isinstance(original, UpdateError):
raise
raise UpdateError("denylist update failed") from original
elif needs_state_write:
pending_state["applied_sha256"] = desired_digest
_atomic_write(self.state_path, _encode_state(pending_state), 0o600)
return {
"operation": operation,
"ip": canonical,
"expired": expired,
"bans": dict(bans),
}
def _emit(result):
bans = result["bans"]
if result["operation"] == "status":
addresses = sorted(
bans, key=lambda value: (ipaddress.ip_address(value).version, int(ipaddress.ip_address(value)))
)
payload = {
"active": len(addresses),
"bans": [
{"ip": address, "expires_at": bans[address]}
for address in addresses[:256]
],
"event": "admin_denylist_status",
"truncated": len(addresses) > 256,
}
else:
payload = {
"active": len(bans),
"event": "admin_denylist_" + result["operation"],
"expired": result["expired"],
}
if result["ip"] is not None:
payload["ip"] = result["ip"]
print(json.dumps(payload, sort_keys=True, separators=(",", ":")), flush=True)
def parse_args(argv=None):
parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument("--state-path", default="/var/lib/truf-edge/admin-denylist.json")
parser.add_argument("--snippet-path", default="/etc/truf-edge/denylist/admin-denylist.caddy")
parser.add_argument("--project-directory", default="/opt/truf")
parser.add_argument("--env-file", default="/etc/truf-edge/edge.env")
commands = parser.add_subparsers(dest="operation", required=True)
for name in ("ban", "unban"):
command = commands.add_parser(name, allow_abbrev=False)
command.add_argument("ip")
commands.add_parser("expire", allow_abbrev=False)
commands.add_parser("status", allow_abbrev=False)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
updater = DenylistUpdater(
args.state_path,
args.snippet_path,
project_directory=args.project_directory,
env_file=args.env_file,
)
try:
result = updater.update(args.operation, getattr(args, "ip", None))
except Exception as exc:
payload = {
"event": "admin_denylist_error",
"operation": args.operation,
"reason": type(exc).__name__,
}
print(json.dumps(payload, sort_keys=True, separators=(",", ":")), file=sys.stderr)
return 1
_emit(result)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+8
View File
@@ -0,0 +1,8 @@
d /etc/truf/runtime 0755 root root -
d /etc/truf/worker-packages 0755 root root -
d /var/lib/truf/runtime-document-candidates 0700 10001 10001 -
d /var/lib/truf/host-agent 0700 root root -
d /var/lib/truf/host-agent/backups 0700 root root -
d /var/lib/truf/host-agent/operations 0700 root root -
d /var/lib/truf/host-agent/results 0750 root 10001 -
d /run/truf-postgres 0700 10001 10001 -
+50
View File
@@ -0,0 +1,50 @@
[Unit]
Description=Truf privileged host operations agent
Requires=truf-host-agent.socket docker.service
After=truf-host-agent.socket docker.service
[Service]
Type=exec
User=root
Group=root
UMask=0077
WorkingDirectory=/
ExecStartPre=/usr/bin/python3 -I -B /usr/lib/truf-host-agent/truf_host_agent_install.py validate
ExecStart=/usr/bin/python3 -I -B /usr/lib/truf-host-agent/truf_host_agent.py
RuntimeDirectory=truf-host-agent
RuntimeDirectoryMode=0700
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_CHOWN CAP_DAC_OVERRIDE CAP_FOWNER
AmbientCapabilities=
PrivateTmp=yes
PrivateDevices=yes
PrivateNetwork=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid
RestrictAddressFamilies=AF_UNIX
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
ReadWritePaths=/etc/truf/runtime
ReadWritePaths=/var/lib/truf/host-agent
ReadWritePaths=/run/truf-host-agent
ReadWritePaths=/run/docker.sock
ReadOnlyPaths=/usr/lib/truf-host-agent
ReadOnlyPaths=/opt/truf
ReadOnlyPaths=/var/lib/truf/runtime-document-candidates
ReadOnlyPaths=/run/truf-postgres
Restart=no
TimeoutStopSec=45min
StandardOutput=null
StandardError=journal
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Truf privileged host operations socket
[Socket]
ListenStream=/run/truf/host-agent.sock
SocketUser=root
SocketGroup=truf-runtime
SocketMode=0660
DirectoryMode=0755
Service=truf-host-agent.service
Accept=no
RemoveOnStop=yes
Backlog=8
[Install]
WantedBy=sockets.target
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
import signal
import sys
import threading
from pathlib import Path
APP_DIRECTORY = Path('/opt/truf/app')
sys.path.insert(0, str(APP_DIRECTORY))
from host_agent_runtime import FixedHostOperationDispatcher
from host_agent_server import (
HostAgentServerError,
inherited_systemd_listener,
serve_forever,
)
def main():
if len(sys.argv) != 1:
raise HostAgentServerError('arguments_forbidden')
listener = inherited_systemd_listener()
stop_event = threading.Event()
dispatcher = FixedHostOperationDispatcher()
def request_stop(_signum, _frame):
stop_event.set()
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
try:
with listener:
serve_forever(
listener, handler=dispatcher.handle, stop_event=stop_event,
)
finally:
dispatcher.close()
return 0
if __name__ == '__main__':
try:
result = main()
except BaseException as exc:
if not isinstance(exc, Exception):
raise
print(
'host operations agent failed (' + type(exc).__name__ + '); details withheld',
file=sys.stderr,
)
result = 1
raise SystemExit(result)
@@ -0,0 +1,571 @@
#!/usr/bin/env python3
"""Install or validate the fixed Truf host-agent deployment."""
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
PROJECT = Path('/opt/truf')
DEPLOY = PROJECT / 'deploy/host-agent'
INSTALL_ROOT = Path('/usr/lib/truf-host-agent')
SYSTEMD = Path('/etc/systemd/system')
TMPFILES = Path('/etc/tmpfiles.d/truf-host-agent.conf')
ACTIVE = Path('/etc/truf/runtime')
WORKER_PACKAGES = Path('/etc/truf/worker-packages')
DEPLOYMENT_PROFILE = Path('/etc/truf/deployment-profile')
AGENT_SOCKET = Path('/run/truf/host-agent.sock')
RUNTIME_UID = RUNTIME_GID = 10001
RUNTIME_GROUP = 'truf-runtime'
MAX_COPY_BYTES = 4 * 1024 * 1024
MAX_COMPOSE_OUTPUT_BYTES = 4 * 1024 * 1024
UNITS = ('truf-host-agent.socket', 'truf-host-agent.service')
SCRIPTS = ('truf_host_agent.py', 'truf_host_agent_install.py')
FIXED_ENV = {
'PATH': '/usr/sbin:/usr/bin:/sbin:/bin',
'LANG': 'C',
'LC_ALL': 'C',
}
STANDALONE_PROFILE = {
'name': 'standalone-edge-v1',
'compose_files': ('compose.yaml', 'compose.edge.yaml'),
'runtime_network': None,
'runtime_ports': [{
'mode': 'host', 'target': 443, 'published': '443', 'protocol': 'tcp',
}],
'runtime_cpus': 2.0,
'runtime_mem_limit': str(6 * 1024 ** 3),
'edge_cap_add': ['NET_BIND_SERVICE'],
'data_volume': {'name': 'truf-docker_data'},
}
SHARED_HOST_PROFILE = {
'name': 'shared-host-edge-v1',
'compose_files': ('compose.yaml', 'compose.shared-host.yaml'),
'runtime_network': 'host',
'runtime_ports': None,
'runtime_cpus': 0.9,
'runtime_mem_limit': str(720 * 1024 ** 2),
'edge_cap_add': None,
'data_volume': {'name': 'truf-remote-server-data', 'external': True},
}
def _compose_config_command(profile):
return (
'/usr/bin/docker', 'compose', '--ansi', 'never', '--project-name',
'truf-docker', '--env-file', '/etc/truf-edge/edge.env',
'--project-directory', '/opt/truf',
*(item for name in profile['compose_files'] for item in (
'--file', '/opt/truf/' + name,
)),
'config', '--format', 'json',
)
COMPOSE_CONFIG_COMMAND = _compose_config_command(STANDALONE_PROFILE)
EXPECTED_RUNTIME_MOUNTS = (
('volume', 'data', '/data', False, None),
('bind', '/etc/truf/runtime', '/data/config', True, False),
('bind', '/etc/truf/worker-packages', '/data/worker-packages', True, False),
(
'bind', '/var/lib/truf/runtime-document-candidates',
'/data/runtime-document-candidates', False, False,
),
('bind', '/run/truf/host-agent.sock', '/run/truf/host-agent.sock', True, False),
(
'bind', '/var/lib/truf/host-agent/results',
'/data/host-agent-results', True, False,
),
('bind', '/run/truf-postgres', '/run/truf-postgres', False, False),
)
class InstallError(RuntimeError):
def __init__(self, category):
self.category = str(category)
super().__init__('host-agent deployment validation failed')
def _deployment_profile():
descriptor = None
try:
descriptor = os.open(
DEPLOYMENT_PROFILE,
os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0)
| getattr(os, 'O_NOFOLLOW', 0),
)
before = os.fstat(descriptor)
if (
not stat.S_ISREG(before.st_mode) or before.st_uid != 0
or before.st_gid != 0 or stat.S_IMODE(before.st_mode) != 0o444
or before.st_nlink != 1 or before.st_size > 64
):
raise InstallError('profile')
payload = os.read(descriptor, 65)
after = os.fstat(descriptor)
if (
len(payload) > 64 or before.st_dev != after.st_dev
or before.st_ino != after.st_ino or before.st_mode != after.st_mode
or before.st_uid != after.st_uid or before.st_gid != after.st_gid
or before.st_nlink != after.st_nlink or before.st_size != after.st_size
or before.st_mtime_ns != after.st_mtime_ns
):
raise InstallError('profile')
except FileNotFoundError:
return STANDALONE_PROFILE
except InstallError:
raise
except OSError:
raise InstallError('profile') from None
finally:
if descriptor is not None:
os.close(descriptor)
try:
name = payload.decode('ascii').strip()
except UnicodeDecodeError:
raise InstallError('profile') from None
profiles = {
STANDALONE_PROFILE['name']: STANDALONE_PROFILE,
SHARED_HOST_PROFILE['name']: SHARED_HOST_PROFILE,
}
if name not in profiles:
raise InstallError('profile')
return profiles[name]
def _run(command, timeout=120):
try:
subprocess.run(
tuple(command), stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, cwd='/', env=FIXED_ENV, shell=False,
timeout=timeout, check=True,
)
except Exception:
raise InstallError('command') from None
def _capture(command, timeout=120):
try:
result = subprocess.run(
tuple(command), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, cwd='/', env=FIXED_ENV, shell=False,
timeout=timeout, check=True,
)
if len(result.stdout) > MAX_COMPOSE_OUTPUT_BYTES:
raise InstallError('command')
return result.stdout
except InstallError:
raise
except Exception:
raise InstallError('command') from None
def _unit_active(unit):
if unit not in UNITS:
raise InstallError('command')
try:
result = subprocess.run(
('/usr/bin/systemctl', 'is-active', '--quiet', unit),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, cwd='/', env=FIXED_ENV, shell=False,
timeout=120, check=False,
)
except Exception:
raise InstallError('command') from None
if result.returncode not in (0, 3, 4):
raise InstallError('command')
return result.returncode == 0
def _validate_compose_projection(payload, profile=None):
profile = profile or STANDALONE_PROFILE
try:
projection = json.loads(payload)
if type(projection) is not dict or projection.get('name') != 'truf-docker':
raise InstallError('compose')
volume_definitions = projection.get('volumes')
if (
type(volume_definitions) is not dict
or volume_definitions.get('data') != profile['data_volume']
):
raise InstallError('compose')
services = projection.get('services')
runtime = services.get('runtime') if type(services) is dict else None
edge = services.get('edge') if type(services) is dict else None
if (
type(runtime) is not dict or type(edge) is not dict
or runtime.get('network_mode') != profile['runtime_network']
or runtime.get('ports') != profile['runtime_ports']
or runtime.get('cpus') != profile['runtime_cpus']
or runtime.get('mem_limit') != profile['runtime_mem_limit']
or edge.get('network_mode') != 'service:runtime'
or edge.get('cap_add') != profile['edge_cap_add']
or edge.get('image') != 'truf-local:edge'
):
raise InstallError('compose')
mounts = runtime.get('volumes') if type(runtime) is dict else None
if type(mounts) is not list:
raise InstallError('compose')
observed = []
for mount in mounts:
if type(mount) is not dict or type(mount.get('read_only', False)) is not bool:
raise InstallError('compose')
kind = mount.get('type')
if kind == 'volume':
if set(mount) - {'type', 'source', 'target', 'read_only'}:
raise InstallError('compose')
create_host_path = None
elif kind == 'bind':
if set(mount) - {'type', 'source', 'target', 'read_only', 'bind'}:
raise InstallError('compose')
binding = mount.get('bind')
if binding not in ({}, {'create_host_path': False}):
raise InstallError('compose')
create_host_path = False
else:
raise InstallError('compose')
observed.append((
kind, mount.get('source'), mount.get('target'),
mount.get('read_only', False), create_host_path,
))
if tuple(observed) != EXPECTED_RUNTIME_MOUNTS:
raise InstallError('compose')
except InstallError:
raise
except Exception:
raise InstallError('compose') from None
def _details(path, *, directory, uid, gid, mode):
try:
value = os.stat(path, follow_symlinks=False)
except OSError:
raise InstallError('metadata') from None
expected = stat.S_ISDIR if directory else stat.S_ISREG
if (
not expected(value.st_mode) or value.st_uid != uid or value.st_gid != gid
or stat.S_IMODE(value.st_mode) != mode
or (not directory and value.st_nlink != 1)
):
raise InstallError('metadata')
return value
def _read_source(path, maximum=MAX_COPY_BYTES):
descriptor = None
try:
descriptor = os.open(
path, os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0)
| getattr(os, 'O_NOFOLLOW', 0),
)
before = os.fstat(descriptor)
if (
not stat.S_ISREG(before.st_mode) or before.st_nlink != 1
or before.st_uid != 0 or stat.S_IMODE(before.st_mode) & 0o022
):
raise InstallError('source')
with os.fdopen(descriptor, 'rb') as handle:
descriptor = None
payload = handle.read(maximum + 1)
after = os.fstat(handle.fileno())
if len(payload) > maximum or (before.st_dev, before.st_ino, before.st_size) != (
after.st_dev, after.st_ino, after.st_size,
):
raise InstallError('source')
return payload
except InstallError:
raise
except Exception:
raise InstallError('source') from None
finally:
if descriptor is not None:
os.close(descriptor)
def _secure_tree(path):
try:
root = os.stat(path, follow_symlinks=False)
if (
not stat.S_ISDIR(root.st_mode)
or root.st_uid != 0
or stat.S_IMODE(root.st_mode) & 0o022
):
raise InstallError('project')
for current, directories, files in os.walk(path, topdown=True, followlinks=False):
current_path = Path(current)
entries = ((name, True) for name in directories)
entries = tuple(entries) + tuple((name, False) for name in files)
current_details = os.stat(current_path, follow_symlinks=False)
if (
not stat.S_ISDIR(current_details.st_mode)
or current_details.st_uid != 0
or stat.S_IMODE(current_details.st_mode) & 0o022
):
raise InstallError('project')
for name, directory in entries:
details = os.stat(current_path / name, follow_symlinks=False)
expected = stat.S_ISDIR if directory else stat.S_ISREG
if (
not expected(details.st_mode)
or details.st_uid != 0
or stat.S_IMODE(details.st_mode) & 0o022
or (not directory and details.st_nlink != 1)
):
raise InstallError('project')
except InstallError:
raise
except Exception:
raise InstallError('project') from None
def _same_file(installed, source):
if not _read_source(installed) == _read_source(source):
raise InstallError('installed_content')
def _ensure_install_root():
try:
INSTALL_ROOT.mkdir(mode=0o755)
except FileExistsError:
pass
except OSError:
raise InstallError('write') from None
_details(INSTALL_ROOT, directory=True, uid=0, gid=0, mode=0o755)
def _root_directory(path):
try:
details = os.stat(path, follow_symlinks=False)
except OSError:
raise InstallError('metadata') from None
if (
not stat.S_ISDIR(details.st_mode)
or details.st_uid != 0
or stat.S_IMODE(details.st_mode) & 0o022
):
raise InstallError('metadata')
def _secure_executable(path):
try:
link = os.lstat(path)
resolved = os.path.realpath(path)
target = os.stat(path)
parent = os.stat(Path(path).parent, follow_symlinks=False)
except OSError:
raise InstallError('executable') from None
if (
link.st_uid != 0
or not (stat.S_ISREG(link.st_mode) or stat.S_ISLNK(link.st_mode))
or not resolved.startswith(('/usr/bin/', '/usr/sbin/'))
or not stat.S_ISREG(target.st_mode) or target.st_uid != 0
or stat.S_IMODE(target.st_mode) & 0o022
or not stat.S_ISDIR(parent.st_mode) or parent.st_uid != 0
or stat.S_IMODE(parent.st_mode) & 0o022
):
raise InstallError('executable')
def _runtime_group_exists():
if sys.platform != 'linux':
raise InstallError('group')
try:
import grp
named = grp.getgrnam(RUNTIME_GROUP)
numbered = grp.getgrgid(RUNTIME_GID)
except KeyError:
return False
except Exception:
raise InstallError('group') from None
if named.gr_gid != RUNTIME_GID or numbered.gr_name != RUNTIME_GROUP:
raise InstallError('group')
return True
def _ensure_runtime_group():
if _runtime_group_exists():
return
try:
import grp
grp.getgrgid(RUNTIME_GID)
except KeyError:
pass
except Exception:
raise InstallError('group') from None
else:
raise InstallError('group')
_secure_executable('/usr/sbin/groupadd')
_run(('/usr/sbin/groupadd', '--system', '--gid', str(RUNTIME_GID), RUNTIME_GROUP))
if not _runtime_group_exists():
raise InstallError('group')
def _validate_agent_socket():
try:
details = os.stat(AGENT_SOCKET, follow_symlinks=False)
except OSError:
raise InstallError('socket') from None
if (
not stat.S_ISSOCK(details.st_mode)
or details.st_uid != 0
or details.st_gid != RUNTIME_GID
or stat.S_IMODE(details.st_mode) != 0o660
):
raise InstallError('socket')
def _write(path, payload, *, uid, gid, mode, replace):
temporary = path.parent / ('.' + path.name + '.truf-install')
descriptor = None
try:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
descriptor = os.open(
temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL
| getattr(os, 'O_NOFOLLOW', 0), mode,
)
os.fchmod(descriptor, mode)
os.fchown(descriptor, uid, gid)
view = memoryview(payload)
while view:
written = os.write(descriptor, view)
if written <= 0:
raise OSError('short write')
view = view[written:]
os.fsync(descriptor)
os.close(descriptor)
descriptor = None
if not replace and path.exists():
os.unlink(temporary)
return
os.replace(temporary, path)
parent = os.open(path.parent, os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0))
try:
os.fsync(parent)
finally:
os.close(parent)
except Exception:
try:
os.unlink(temporary)
except OSError:
pass
raise InstallError('write') from None
finally:
if descriptor is not None:
os.close(descriptor)
payload = None
def validate(*, require_socket=True):
if sys.platform != 'linux' or not hasattr(os, 'geteuid') or os.geteuid() != 0:
raise InstallError('root')
_root_directory(PROJECT.parent)
_root_directory(INSTALL_ROOT.parent)
if not _runtime_group_exists():
raise InstallError('group')
_details(PROJECT, directory=True, uid=0, gid=0, mode=0o755)
_details(DEPLOY, directory=True, uid=0, gid=0, mode=0o755)
_details(INSTALL_ROOT, directory=True, uid=0, gid=0, mode=0o755)
_secure_tree(PROJECT / 'app')
_details(ACTIVE, directory=True, uid=0, gid=0, mode=0o755)
_details(WORKER_PACKAGES, directory=True, uid=0, gid=0, mode=0o755)
_details(ACTIVE / 'config.yaml', directory=False, uid=RUNTIME_UID, gid=RUNTIME_GID, mode=0o600)
_details(ACTIVE / 'secrets.yaml', directory=False, uid=RUNTIME_UID, gid=RUNTIME_GID, mode=0o600)
layouts = (
('/var/lib/truf/runtime-document-candidates', RUNTIME_UID, RUNTIME_GID, 0o700),
('/var/lib/truf/host-agent', 0, 0, 0o700),
('/var/lib/truf/host-agent/backups', 0, 0, 0o700),
('/var/lib/truf/host-agent/operations', 0, 0, 0o700),
('/var/lib/truf/host-agent/results', 0, RUNTIME_GID, 0o750),
('/run/truf-postgres', RUNTIME_UID, RUNTIME_GID, 0o700),
)
for path, uid, gid, mode in layouts:
_details(Path(path), directory=True, uid=uid, gid=gid, mode=mode)
for executable in (
'/usr/bin/python3', '/usr/bin/docker', '/usr/bin/systemctl',
'/usr/bin/systemd-analyze', '/usr/bin/systemd-tmpfiles',
'/usr/sbin/groupadd',
):
_secure_executable(executable)
for unit in UNITS:
_details(SYSTEMD / unit, directory=False, uid=0, gid=0, mode=0o644)
_same_file(SYSTEMD / unit, DEPLOY / unit)
_details(TMPFILES, directory=False, uid=0, gid=0, mode=0o644)
_same_file(TMPFILES, DEPLOY / 'truf-host-agent.conf')
for script in SCRIPTS:
_details(INSTALL_ROOT / script, directory=False, uid=0, gid=0, mode=0o755)
_same_file(INSTALL_ROOT / script, DEPLOY / script)
profile = _deployment_profile()
for compose_file in profile['compose_files']:
_read_source(PROJECT / compose_file)
try:
docker_socket = os.stat('/run/docker.sock', follow_symlinks=False)
except OSError:
raise InstallError('socket') from None
if (
not stat.S_ISSOCK(docker_socket.st_mode)
or docker_socket.st_uid != 0
or stat.S_IMODE(docker_socket.st_mode) & 0o002
):
raise InstallError('socket')
if require_socket:
_validate_agent_socket()
_run(('/usr/bin/python3', '-I', '-B', '-c', 'import psycopg, yaml'))
_run(('/usr/bin/docker', 'compose', 'version'))
_run(('/usr/bin/systemd-analyze', 'verify', *(str(SYSTEMD / unit) for unit in UNITS)))
_validate_compose_projection(
_capture(_compose_config_command(profile)), profile,
)
def install():
if sys.platform != 'linux' or not hasattr(os, 'geteuid') or os.geteuid() != 0:
raise InstallError('root')
_ensure_runtime_group()
for unit in UNITS:
if _unit_active(unit):
_run(('/usr/bin/systemctl', 'stop', unit))
_ensure_install_root()
for script in SCRIPTS:
_write(INSTALL_ROOT / script, _read_source(DEPLOY / script), uid=0, gid=0, mode=0o755, replace=True)
for unit in UNITS:
_write(SYSTEMD / unit, _read_source(DEPLOY / unit), uid=0, gid=0, mode=0o644, replace=True)
_write(TMPFILES, _read_source(DEPLOY / 'truf-host-agent.conf'), uid=0, gid=0, mode=0o644, replace=True)
_run(('/usr/bin/systemd-tmpfiles', '--create', str(TMPFILES)))
_write(
ACTIVE / 'config.yaml', _read_source(PROJECT / 'app/config.linux.yaml'),
uid=RUNTIME_UID, gid=RUNTIME_GID, mode=0o600, replace=False,
)
_write(
ACTIVE / 'secrets.yaml', b'{}\n', uid=RUNTIME_UID, gid=RUNTIME_GID,
mode=0o600, replace=False,
)
validate(require_socket=False)
_run(('/usr/bin/systemctl', 'daemon-reload'))
_run(('/usr/bin/systemctl', 'enable', 'truf-host-agent.socket'))
_run(('/usr/bin/systemctl', 'restart', 'truf-host-agent.socket'))
_validate_agent_socket()
def main():
if len(sys.argv) != 2 or sys.argv[1] not in ('install', 'validate'):
raise InstallError('arguments')
install() if sys.argv[1] == 'install' else validate()
return 0
if __name__ == '__main__':
try:
result = main()
except Exception as exc:
print(
'host-agent deployment failed (' + type(exc).__name__ + '); details withheld',
file=sys.stderr,
)
result = 1
raise SystemExit(result)
@@ -0,0 +1,18 @@
[Unit]
Description=Expire Truf Caddy admin-only denylist entries
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
User=root
Group=root
ExecStart=/usr/local/sbin/truf-caddy-admin-denylist expire
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/truf-edge /etc/truf-edge/denylist
RestrictAddressFamilies=AF_UNIX
LockPersonality=true
MemoryDenyWriteExecute=true
@@ -0,0 +1,11 @@
[Unit]
Description=Expire Truf Caddy admin-only denylist entries every minute
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
Persistent=true
AccuracySec=10s
[Install]
WantedBy=timers.target
+27
View File
@@ -0,0 +1,27 @@
name: truf-worker
services:
worker:
image: truf-remote-worker:linux-x86_64
container_name: truf-worker
restart: unless-stopped
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 256
stop_grace_period: 10m
tmpfs:
- /tmp:rw,nosuid,nodev,noexec,size=128m,mode=1777
environment:
XDG_DATA_HOME: /data/client
XDG_STATE_HOME: /data/state-base
volumes:
- truf-worker-data:/data
command:
- run
volumes:
truf-worker-data:
name: truf-worker-data
+3
View File
@@ -0,0 +1,3 @@
$ErrorActionPreference = 'Stop'
& docker compose exec worker /opt/truf-worker/truf-worker @args
exit $LASTEXITCODE
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
set -eu
exec docker compose exec worker /opt/truf-worker/truf-worker "$@"