296 lines
13 KiB
Python
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()
|