Corp/ops/security/install-corp-proxy-security.py
Игорь Надеин 4f6244ab1f
Some checks failed
Deploy production / deploy (push) Failing after 2s
Fix CORP runtime errors and harden deployment and media access
2026-09-10 16:40:08 +03:00

296 lines
13 KiB
Python

#!/usr/bin/env python3
"""Prepare CORP for Synology TLS termination; preserve upstream/slot management.
Run as root on CORP. Does not redeploy application code or change TLS certificate
files. Port 443 stays available until the Synology destination is switched.
"""
import datetime
import fcntl
import os
from pathlib import Path
import re
import secrets
import stat
import subprocess
import sys
def parent_directory_fd(path):
if not path.is_absolute():
raise ValueError("An absolute configuration path is required")
descriptor = os.open(path.anchor, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
for component in path.parent.parts[1:]:
child = os.open(component, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=descriptor)
os.close(descriptor)
descriptor = child
return descriptor
except BaseException:
os.close(descriptor)
raise
def read_file(path):
parent_fd = parent_directory_fd(path)
try:
descriptor = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent_fd)
with os.fdopen(descriptor, "r") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
raise RuntimeError("Configuration must be a regular file")
return stream.read()
finally:
os.close(parent_fd)
def atomic_update(path, content, expected_source=None):
parent_fd = parent_directory_fd(path)
try:
original = os.open(path.name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent_fd)
except BaseException:
os.close(parent_fd)
raise
committed = False
try:
metadata = os.fstat(original)
if not stat.S_ISREG(metadata.st_mode):
raise RuntimeError("Configuration must remain a regular file")
if expected_source is not None:
with os.fdopen(os.dup(original), "r") as stream:
if stream.read() != expected_source:
raise RuntimeError("Configuration changed concurrently; aborting")
name = f".corp-security-{secrets.token_hex(16)}"
descriptor = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
0o600, dir_fd=parent_fd)
try:
with os.fdopen(descriptor, "w") as stream:
stream.write(content)
stream.flush()
os.fchown(stream.fileno(), metadata.st_uid, metadata.st_gid)
for attribute in os.listxattr(original):
os.setxattr(stream.fileno(), attribute, os.getxattr(original, attribute))
os.fchmod(stream.fileno(), stat.S_IMODE(metadata.st_mode))
os.fsync(stream.fileno())
expected = os.fstat(stream.fileno())
observed = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
if (observed.st_dev, observed.st_ino) != (expected.st_dev, expected.st_ino):
raise RuntimeError("Staging file was replaced; aborting")
current = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False)
if (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns) != (
metadata.st_dev, metadata.st_ino, metadata.st_size, metadata.st_mtime_ns
):
raise RuntimeError("Configuration was replaced concurrently; aborting")
os.replace(name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
committed = True
os.fsync(parent_fd)
finally:
try:
os.unlink(name, dir_fd=parent_fd)
except FileNotFoundError:
pass
except BaseException as failure:
failure.atomic_update_committed = committed
raise
finally:
os.close(original)
os.close(parent_fd)
def main():
if os.geteuid() != 0:
raise SystemExit("Run with sudo on CORP.")
with open("/run/lock/eusrr-deploy.lock", "a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
configure_proxy()
def configure_proxy():
nginx_path = Path("/etc/nginx/sites-available/eusrr")
env_path = Path("/srv/eusrr/backend/.env")
if nginx_path.is_symlink() or not nginx_path.is_file():
raise SystemExit("Unexpected Nginx configuration path.")
if env_path.is_symlink() or not env_path.is_file():
raise SystemExit("Unexpected application environment path.")
source = original_source = read_file(nginx_path)
env_source = original_env = read_file(env_path)
required = [
"server_name 172.11.0.11",
"proxy_pass http://eusrr_backend;",
"proxy_pass http://eusrr_frontend;",
]
if not all(value in source for value in required):
raise SystemExit("Nginx layout differs; review manually instead of guessing.")
# Port 80 is only the trusted TLS proxy's backend (and loopback health checks).
# Overwrite rather than trust a client's X-Forwarded-Proto header.
marker = "# CORP trusted TLS termination"
if marker not in source:
source = source.replace(
"server_name 172.11.0.11;",
"server_name 172.11.0.11;\n"
" " + marker + "\n"
" allow 172.11.1.254;\n allow 127.0.0.1;\n allow ::1;\n deny all;",
1,
)
# Named HTTP requests from the NAS must use the application vhost too.
# Direct untrusted LAN access is still denied by the source ACL above.
source = source.replace(
"server_name 172.11.0.11;", "server_name 172.11.0.11 corp.robotail.pro;", 1
)
source = source.replace(
"server_name corp.robotail.local corp.robotail.pro 127.0.0.1;",
"server_name corp.robotail.local 127.0.0.1;",
)
source = re.sub(
r"(proxy_set_header\s+X-Forwarded-Proto\s+)\$scheme;", r"\1https;", source
)
if "--http-only" in sys.argv:
# Remove only the verified old CORP TLS block, leaving certificates on disk.
for match in reversed(list(re.finditer(r"(?m)^server \{", source))):
depth = 0
end = None
for index in range(source.index("{", match.start()), len(source)):
depth += (source[index] == "{") - (source[index] == "}")
if depth == 0:
end = index + 1
break
if end is None:
raise SystemExit("Unbalanced Nginx block; aborting.")
block = source[match.start() : end]
if "listen 443 ssl" in block:
if "/etc/ssl/localcerts/corp.robotail.pro.cert.pem" not in block:
raise SystemExit("Unexpected TLS vhost; aborting.")
source = source[: match.start()] + source[end:]
for location in ("location /ws/ {", "location ^~ /o/ {"):
secured = location + "\n error_log /var/log/nginx/error.log crit;"
if secured not in source:
# Nginx error logs cannot redact request queries. Keep critical errors
# and query-free status access logs for these credential-bearing URLs.
source = source.replace(location, secured)
internal_prefixes = {
"/_protected_documents/": "/srv/eusrr/backend/media/documents/",
"/_protected_chat_attachments/": "/srv/eusrr/backend/media/chat_attachments/",
}
for prefix, alias in internal_prefixes.items():
if f"location ^~ {prefix} {{" not in source:
source = source.replace(
" location /media/ {",
f" location ^~ {prefix} {{\n"
" internal;\n"
f" alias {alias};\n"
" add_header Cache-Control 'private, no-store' always;\n"
" add_header X-Content-Type-Options nosniff always;\n"
" }\n\n location /media/ {",
)
if "--protect-media" in sys.argv:
protected = (
"/media/documents/",
"/smedia/documents/",
"/media/task_attachments/",
"/smedia/task_attachments/",
"/media/task_attachment_thumbnails/",
"/smedia/task_attachment_thumbnails/",
"/media/procurement/item_attachments/",
"/smedia/procurement/item_attachments/",
"/media/procurement/item_attachment_thumbnails/",
"/smedia/procurement/item_attachment_thumbnails/",
"/media/requests/attachments/",
"/smedia/requests/attachments/",
"/media/chat_attachments/",
"/smedia/chat_attachments/",
)
for prefix in protected:
location = f" location ^~ {prefix} {{ return 404; }}"
if location not in source:
source = source.replace(
" location /media/ {", location + "\n location /media/ {"
)
# Access logs must not persist OAuth codes, WS tickets or arbitrary queries.
# No Referer field either: it can carry those same query parameters.
if "log_format corp_safe " not in source:
source = (
"log_format corp_safe '$remote_addr - $remote_user [$time_local] "
'"$request_method $uri $server_protocol" $status $body_bytes_sent\';\n'
"access_log /var/log/nginx/corp-access.log corp_safe;\n" + source
)
source = re.sub(
r"(?m)^server \{\n(?! access_log /var/log/nginx/corp-access.log corp_safe;)",
"server {\n access_log /var/log/nginx/corp-access.log corp_safe;\n",
source,
)
media_locations = source.count("location /media/ {")
if media_locations not in (1, 2) or any(
source.count(f"location ^~ {prefix} {{") != media_locations
for prefix in internal_prefixes
):
raise SystemExit("Unexpected media vhost layout; protected download alias is incomplete.")
if "--protect-media" in sys.argv and any(
source.count(f"location ^~ {prefix} {{ return 404; }}") != media_locations
for prefix in protected
):
raise SystemExit("Protected media paths are not blocked in every application vhost.")
values = {
"CSRF_COOKIE_SECURE": "true",
"TRUST_PROXY_SSL_HEADER": "true",
"SECURE_SSL_REDIRECT": "true",
"SECURE_HSTS_SECONDS": "3600",
"LOG_TRUST_X_FORWARDED_FOR": "true",
"LOG_TRUSTED_PROXY_CIDRS": "127.0.0.0/8,::1/128,172.11.1.254/32",
"OIDC_LEGACY_NO_PKCE_CLIENT_IDS": "gitea",
"DOCUMENTS_USE_X_ACCEL_REDIRECT": "true",
"CHAT_ATTACHMENTS_USE_X_ACCEL_REDIRECT": "true",
}
for key, value in values.items():
line = f"{key}={value}"
pattern = rf"(?m)^{re.escape(key)}=.*$"
if re.search(pattern, env_source):
env_source = re.sub(pattern, line, env_source)
else:
env_source = env_source.rstrip() + "\n" + line + "\n"
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
backup = Path("/var/lib/eusrr-deploy") / f"proxy-security-backup-{stamp}"
backup.mkdir(mode=0o700)
(backup / "nginx.conf").write_text(original_source)
(backup / "backend.env").write_text(original_env)
(backup / "nginx.conf").chmod(0o600)
(backup / "backend.env").chmod(0o600)
modified = []
try:
for path, after, before in ((nginx_path, source, original_source), (env_path, env_source, original_env)):
try:
atomic_update(path, after, expected_source=before)
except BaseException as failure:
if getattr(failure, "atomic_update_committed", False):
modified.append((path, after, before))
raise
modified.append((path, after, before))
subprocess.run(["nginx", "-t"], check=True)
subprocess.run(["systemctl", "reload", "nginx"], check=True)
except BaseException as failure:
recovery_errors = []
for path, after, before in reversed(modified):
try:
atomic_update(path, before, expected_source=after)
except BaseException:
recovery_errors.append(path.name)
if modified:
try:
subprocess.run(["nginx", "-t"], check=True)
subprocess.run(["systemctl", "reload", "nginx"], check=True)
except BaseException:
recovery_errors.append("nginx validation/reload")
if recovery_errors:
raise RuntimeError(f"Proxy recovery incomplete; inspect root backup {backup}") from failure
raise
print(
f"Proxy configured. TLS mode: {'NAS-only' if '--http-only' in sys.argv else 'legacy preserved'}. Root-only backup: {backup}"
)
print("Application flags become effective with the next standard deployment.")
if __name__ == "__main__":
main()