chore(ops): 新增 RLS preflight 與 registry certbot 修復包
All checks were successful
Code Review / ai-code-review (push) Successful in 13s

This commit is contained in:
Your Name
2026-05-12 18:25:53 +08:00
parent a18e2f9c3f
commit 0bc1878778
6 changed files with 752 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Repair helper for 188 registry.wooo.work HTTP-01 renewal.
# Default is dry-run. Use --apply on 188 as root after reviewing the plan.
set -euo pipefail
APPLY=0
DOMAIN="${REGISTRY_CERTBOT_DOMAIN:-registry.wooo.work}"
WEBROOT="${REGISTRY_CERTBOT_WEBROOT:-/var/www/certbot}"
NGINX_SNIPPET="${REGISTRY_CERTBOT_NGINX_SNIPPET:-/etc/nginx/conf.d/registry-acme-http.conf}"
CERTBOT_BIN="${REGISTRY_CERTBOT_BIN:-/snap/bin/certbot}"
usage() {
cat <<'USAGE'
Usage: sudo bash scripts/ops/188-registry-certbot-fix.sh [--apply]
Fixes the known 188 drift where registry.wooo.work HTTP-01 traffic falls through
to the aiops.wooo.work default server and certbot cannot renew the registry cert.
Default mode is dry-run and prints the exact actions. --apply requires root.
Environment:
REGISTRY_CERTBOT_DOMAIN Default: registry.wooo.work
REGISTRY_CERTBOT_WEBROOT Default: /var/www/certbot
REGISTRY_CERTBOT_NGINX_SNIPPET Default: /etc/nginx/conf.d/registry-acme-http.conf
REGISTRY_CERTBOT_BIN Default: /snap/bin/certbot
USAGE
}
while [ "$#" -gt 0 ]; do
case "$1" in
--apply)
APPLY=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 64
;;
esac
shift
done
run() {
if [ "$APPLY" -eq 1 ]; then
"$@"
else
printf 'DRY-RUN:'
printf ' %q' "$@"
printf '\n'
fi
}
write_snippet() {
local tmp
tmp="$(mktemp)"
cat > "$tmp" <<EOF
# Managed by AWOOOI registry certbot repair.
# LetsEncrypt HTTP-01 must not fall through to aiops.wooo.work.
server {
listen 80;
server_name ${DOMAIN};
location /.well-known/acme-challenge/ {
root ${WEBROOT};
default_type "text/plain";
}
location / {
return 301 https://\$host\$request_uri;
}
}
EOF
run install -m 0644 "$tmp" "$NGINX_SNIPPET"
rm -f "$tmp"
}
if [ "$APPLY" -eq 1 ] && [ "$(id -u)" -ne 0 ]; then
echo "--apply must be run as root on 188" >&2
exit 77
fi
if [ "$APPLY" -eq 1 ] && [ ! -x "$CERTBOT_BIN" ]; then
echo "certbot binary not executable: $CERTBOT_BIN" >&2
exit 69
fi
echo "Plan: repair HTTP-01 route for ${DOMAIN}, renew via ${CERTBOT_BIN}, reload nginx."
run install -d -m 0755 "$WEBROOT"
write_snippet
run nginx -t
run systemctl reload nginx
if [ "$APPLY" -eq 1 ]; then
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "http://${DOMAIN}/.well-known/acme-challenge/codex-route-check" || true)"
if [ "$code" != "404" ]; then
echo "Unexpected ACME route status after nginx reload: ${code}; expected 404 from ${DOMAIN}, not redirect/default vhost" >&2
exit 1
fi
fi
run "$CERTBOT_BIN" renew --cert-name "$DOMAIN" --deploy-hook "systemctl reload nginx"
if [ -x /snap/bin/certbot ]; then
run systemctl disable --now certbot.timer
run systemctl reset-failed certbot.service
fi
if [ "$APPLY" -eq 1 ]; then
openssl x509 -noout -subject -issuer -dates -in "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"
systemctl status snap.certbot.renew.timer --no-pager -l | sed -n '1,25p' || true
else
echo "Dry-run only. Re-run with --apply on 188 as root to execute."
fi

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Read-only AwoooP RLS preflight runner.
#
# Default path runs inside the production API pod through the 120 control-plane
# host, so DATABASE_URL stays inside Kubernetes and is never printed locally.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PY_SCRIPT="${SCRIPT_DIR}/awooop_rls_preflight.py"
NAMESPACE="${AWOOOP_RLS_NAMESPACE:-awoooi-prod}"
DEPLOYMENT="${AWOOOP_RLS_DEPLOYMENT:-deployment/awoooi-api}"
CONTAINER="${AWOOOP_RLS_CONTAINER:-api}"
SSH_TARGET="${AWOOOP_RLS_SSH_TARGET:-wooo@192.168.0.120}"
REMOTE_KUBECTL="${AWOOOP_RLS_REMOTE_KUBECTL:-sudo kubectl}"
KUBECTL="${AWOOOP_RLS_KUBECTL:-kubectl}"
USE_SSH=1
PY_ARGS=()
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=8)
usage() {
cat <<'USAGE'
Usage: bash scripts/ops/awooop-rls-preflight.sh [options]
Read-only checks for AwoooP PostgreSQL RLS readiness. The script runs the Python
probe inside the API pod and exits 2 when RLS is not ready to enable.
Options:
--exact-counts Run exact COUNT(*) project_id backfill checks.
--json Print JSON output from the pod.
--local Use local kubectl instead of SSH to 120.
--ssh USER@HOST Override SSH target. Default: wooo@192.168.0.120.
-h, --help Show this help.
Environment:
AWOOOP_RLS_NAMESPACE Default: awoooi-prod
AWOOOP_RLS_DEPLOYMENT Default: deployment/awoooi-api
AWOOOP_RLS_CONTAINER Default: api
AWOOOP_RLS_REMOTE_KUBECTL Default: sudo kubectl
AWOOOP_RLS_KUBECTL Default: kubectl
USAGE
}
while [ "$#" -gt 0 ]; do
case "$1" in
--exact-counts)
PY_ARGS+=(--exact-counts)
;;
--json)
PY_ARGS+=(--json)
;;
--local)
USE_SSH=0
;;
--ssh)
shift
SSH_TARGET="${1:-}"
if [ -z "$SSH_TARGET" ]; then
echo "--ssh requires USER@HOST" >&2
exit 64
fi
USE_SSH=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 64
;;
esac
shift
done
if [ ! -f "$PY_SCRIPT" ]; then
echo "Missing Python probe: $PY_SCRIPT" >&2
exit 66
fi
if [ "$USE_SSH" -eq 1 ]; then
printf -v namespace_q "%q" "$NAMESPACE"
printf -v deployment_q "%q" "$DEPLOYMENT"
printf -v container_q "%q" "$CONTAINER"
remote_cmd="${REMOTE_KUBECTL} -n ${namespace_q} exec -i ${deployment_q} -c ${container_q} -- python -"
if [ "${#PY_ARGS[@]}" -gt 0 ]; then
for arg in "${PY_ARGS[@]}"; do
printf -v arg_q "%q" "$arg"
remote_cmd="${remote_cmd} ${arg_q}"
done
fi
ssh "${SSH_OPTS[@]}" "$SSH_TARGET" "$remote_cmd" < "$PY_SCRIPT"
else
if [ "${#PY_ARGS[@]}" -gt 0 ]; then
"$KUBECTL" -n "$NAMESPACE" exec -i "$DEPLOYMENT" -c "$CONTAINER" -- python - "${PY_ARGS[@]}" < "$PY_SCRIPT"
else
"$KUBECTL" -n "$NAMESPACE" exec -i "$DEPLOYMENT" -c "$CONTAINER" -- python - < "$PY_SCRIPT"
fi
fi

View File

@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""
Read-only AwoooP RLS preflight.
This script is designed to run inside the production API pod. It uses the
pod-local DATABASE_URL and never prints the URL or credentials.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from dataclasses import asdict, dataclass
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
TARGET_TABLES = [
"incidents",
"knowledge_entries",
"playbooks",
"audit_logs",
"budget_ledger",
"awooop_projects",
"awooop_contracts",
"awooop_contract_revisions",
"awooop_published_contracts",
"awooop_run_state",
"awooop_run_event",
"awooop_cost_ledger",
"awooop_mcp_tool_registry",
"awooop_mcp_grants",
"awooop_mcp_credential_refs",
"awooop_mcp_gateway_audit",
"awooop_conversation_event",
"awooop_outbound_message",
]
REQUIRED_ROLES = [
"awooop_app",
"awooop_platform_admin",
"awooop_migration",
]
@dataclass
class Check:
name: str
status: str
detail: str
def add(checks: list[Check], name: str, status: str, detail: str) -> None:
checks.append(Check(name=name, status=status, detail=detail))
async def scalar(conn: Any, sql: str, params: dict[str, Any] | None = None) -> Any:
return await conn.scalar(text(sql), params or {})
async def rows(conn: Any, sql: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
result = await conn.execute(text(sql), params or {})
return [dict(row._mapping) for row in result.fetchall()]
async def collect(exact_counts: bool) -> tuple[list[Check], dict[str, Any]]:
database_url = os.environ.get("DATABASE_URL")
if not database_url:
return [Check("database_url", "BLOCKED", "DATABASE_URL is not set in this environment")], {}
engine = create_async_engine(database_url, pool_pre_ping=True)
checks: list[Check] = []
evidence: dict[str, Any] = {}
async with engine.connect() as conn:
current_role = await rows(
conn,
"""
SELECT
current_user AS current_user,
session_user AS session_user,
r.rolsuper AS current_user_superuser,
r.rolbypassrls AS current_user_bypassrls
FROM pg_roles r
WHERE r.rolname = current_user
""",
)
evidence["current_role"] = current_role[0] if current_role else {}
role = evidence["current_role"]
if role.get("current_user_superuser") or role.get("current_user_bypassrls"):
add(
checks,
"current_role_rls_enforced",
"BLOCKED",
f"current_user={role.get('current_user')} can bypass RLS",
)
else:
add(
checks,
"current_role_rls_enforced",
"PASS",
f"current_user={role.get('current_user')} is subject to RLS",
)
before = await scalar(conn, "SELECT current_setting('app.project_id', TRUE)")
await scalar(conn, "SELECT set_config('app.project_id', :pid, TRUE)", {"pid": "awoooi"})
after = await scalar(conn, "SELECT current_setting('app.project_id', TRUE)")
evidence["project_context_probe"] = {"before": before, "after": after}
if after == "awoooi":
add(checks, "project_context_set_config", "PASS", "set_config app.project_id works")
else:
add(checks, "project_context_set_config", "BLOCKED", f"expected awoooi, got {after!r}")
roles = await rows(
conn,
"""
WITH required_roles(rolname) AS (
SELECT jsonb_array_elements_text(CAST(:roles_json AS jsonb))
)
SELECT
rr.rolname,
r.rolsuper,
r.rolbypassrls,
r.oid IS NOT NULL AS exists
FROM required_roles rr
LEFT JOIN pg_roles r ON r.rolname = rr.rolname
ORDER BY rr.rolname
""",
{"roles_json": json.dumps(REQUIRED_ROLES)},
)
evidence["required_roles"] = roles
present_roles = {row["rolname"] for row in roles if row["exists"]}
missing_roles = [role_name for role_name in REQUIRED_ROLES if role_name not in present_roles]
if missing_roles:
add(checks, "required_roles", "BLOCKED", f"missing roles: {', '.join(missing_roles)}")
else:
add(checks, "required_roles", "PASS", "all required RLS roles exist")
table_rows = await rows(
conn,
"""
WITH target(relname) AS (
SELECT jsonb_array_elements_text(CAST(:tables_json AS jsonb))
),
rels AS (
SELECT
t.relname,
c.oid,
c.relrowsecurity,
c.relforcerowsecurity,
COALESCE(c.reltuples, 0)::bigint AS estimated_rows
FROM target t
LEFT JOIN pg_class c
ON c.relname = t.relname
AND c.relkind IN ('r', 'p')
AND c.relnamespace = 'public'::regnamespace
),
project_columns AS (
SELECT table_name, TRUE AS has_project_id
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_name = 'project_id'
AND table_name IN (SELECT relname FROM target)
),
policy_stats AS (
SELECT
p.polrelid,
COUNT(*) AS policy_count,
BOOL_OR(
COALESCE(pg_get_expr(p.polqual, p.polrelid), '') ILIKE '%current_setting(''app.project_id'', true) IS NULL%'
OR COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), '') ILIKE '%current_setting(''app.project_id'', true) IS NULL%'
) AS has_null_fail_open_policy,
BOOL_OR(
COALESCE(pg_get_expr(p.polqual, p.polrelid), '') ILIKE '%current_setting(''app.project_id'', true) = ''''%'
OR COALESCE(pg_get_expr(p.polwithcheck, p.polrelid), '') ILIKE '%current_setting(''app.project_id'', true) = ''''%'
) AS has_empty_string_fail_open_policy
FROM pg_policy p
GROUP BY p.polrelid
)
SELECT
r.relname AS table_name,
r.oid IS NOT NULL AS exists,
COALESCE(pc.has_project_id, FALSE) AS has_project_id,
COALESCE(r.relrowsecurity, FALSE) AS rls_enabled,
COALESCE(r.relforcerowsecurity, FALSE) AS rls_forced,
COALESCE(ps.policy_count, 0) AS policy_count,
COALESCE(ps.has_null_fail_open_policy, FALSE) AS has_null_fail_open_policy,
COALESCE(ps.has_empty_string_fail_open_policy, FALSE) AS has_empty_string_fail_open_policy,
r.estimated_rows
FROM rels r
LEFT JOIN project_columns pc ON pc.table_name = r.relname
LEFT JOIN policy_stats ps ON ps.polrelid = r.oid
ORDER BY r.relname
""",
{"tables_json": json.dumps(TARGET_TABLES)},
)
evidence["tables"] = table_rows
existing = [row for row in table_rows if row["exists"]]
missing_project_id = [row["table_name"] for row in existing if not row["has_project_id"]]
if missing_project_id:
add(checks, "project_id_columns", "BLOCKED", f"missing project_id: {', '.join(missing_project_id)}")
else:
add(checks, "project_id_columns", "PASS", "all existing target tables have project_id")
rls_missing = [
row["table_name"]
for row in existing
if not row["rls_enabled"] or not row["rls_forced"] or row["policy_count"] == 0
]
if rls_missing:
add(
checks,
"rls_enabled_forced_policy",
"BLOCKED",
f"RLS not fully enabled/forced/policied: {', '.join(rls_missing)}",
)
else:
add(checks, "rls_enabled_forced_policy", "PASS", "all existing target tables have forced RLS policy")
fail_open = [
row["table_name"]
for row in existing
if row["has_null_fail_open_policy"] or row["has_empty_string_fail_open_policy"]
]
if fail_open:
add(checks, "fail_open_policies", "BLOCKED", f"fail-open policy expressions: {', '.join(fail_open)}")
else:
add(checks, "fail_open_policies", "PASS", "no fail-open policy expressions detected")
if exact_counts:
exact_rows: list[dict[str, Any]] = []
for row in existing:
if not row["has_project_id"]:
continue
quoted = '"' + row["table_name"].replace('"', '""') + '"'
count_row = await rows(
conn,
f"SELECT :table_name AS table_name, COUNT(*) AS total_rows, COUNT(*) FILTER (WHERE project_id IS NULL) AS null_project_id_rows FROM {quoted}",
{"table_name": row["table_name"]},
)
exact_rows.extend(count_row)
evidence["exact_counts"] = exact_rows
null_tables = [row["table_name"] for row in exact_rows if int(row["null_project_id_rows"]) > 0]
if null_tables:
add(checks, "project_id_backfill", "BLOCKED", f"NULL project_id remains: {', '.join(null_tables)}")
else:
add(checks, "project_id_backfill", "PASS", "no NULL project_id rows in counted tables")
else:
add(checks, "project_id_backfill", "WARN", "exact counts skipped; rerun with --exact-counts before enabling RLS")
await engine.dispose()
return checks, evidence
def print_human(checks: list[Check], evidence: dict[str, Any]) -> None:
blocked = sum(1 for check in checks if check.status == "BLOCKED")
warn = sum(1 for check in checks if check.status == "WARN")
passed = sum(1 for check in checks if check.status == "PASS")
print(f"AwoooP RLS preflight: PASS={passed} WARN={warn} BLOCKED={blocked}")
for check in checks:
print(f"{check.status:<7} {check.name}: {check.detail}")
role = evidence.get("current_role") or {}
if role:
print(
"role "
f"current_user={role.get('current_user')} "
f"session_user={role.get('session_user')} "
f"superuser={role.get('current_user_superuser')} "
f"bypassrls={role.get('current_user_bypassrls')}"
)
for row in evidence.get("tables", []):
print(
"table "
f"{row['table_name']} "
f"exists={row['exists']} "
f"project_id={row['has_project_id']} "
f"rls={row['rls_enabled']} "
f"force={row['rls_forced']} "
f"policies={row['policy_count']} "
f"fail_open_null={row['has_null_fail_open_policy']} "
f"fail_open_empty={row['has_empty_string_fail_open_policy']} "
f"estimated_rows={row['estimated_rows']}"
)
for row in evidence.get("exact_counts", []):
print(
"count "
f"{row['table_name']} "
f"total_rows={row['total_rows']} "
f"null_project_id_rows={row['null_project_id_rows']}"
)
async def main() -> int:
parser = argparse.ArgumentParser(description="Run read-only AwoooP RLS preflight checks.")
parser.add_argument("--exact-counts", action="store_true", help="Run exact COUNT(*) checks for project_id backfill.")
parser.add_argument("--json", action="store_true", help="Print JSON instead of human-readable output.")
args = parser.parse_args()
checks, evidence = await collect(exact_counts=args.exact_counts)
blocked = any(check.status == "BLOCKED" for check in checks)
if args.json:
print(
json.dumps(
{"checks": [asdict(check) for check in checks], "evidence": evidence},
ensure_ascii=False,
default=str,
)
)
else:
print_human(checks, evidence)
return 2 if blocked else 0
if __name__ == "__main__":
try:
raise SystemExit(asyncio.run(main()))
except KeyboardInterrupt:
raise SystemExit(130)
except Exception as exc:
print(f"BLOCKED preflight_exception: {exc}", file=sys.stderr)
raise SystemExit(2)