fix: harden node upgrades and prepare 1.9.0-alpha
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keep default dashboard vhosts private while allowing HTTP-01 challenges.
|
||||
|
||||
Apply only to Archipelago's default HTTP/HTTPS servers. Named NPM public
|
||||
services remain separate. Never trust Host, XFF or rewritten client addresses
|
||||
as evidence that a request came from a private network.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import fcntl
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
BEGIN = '# BEGIN ARCHIPELAGO MANAGEMENT SOURCE GUARD'
|
||||
END = '# END ARCHIPELAGO MANAGEMENT SOURCE GUARD'
|
||||
GUARD = '''# BEGIN ARCHIPELAGO MANAGEMENT SOURCE GUARD
|
||||
# Use the original socket peer, before any real_ip / forwarded-header rewrite.
|
||||
geo $realip_remote_addr $archy_management_private_source {
|
||||
default 0;
|
||||
127.0.0.0/8 1;
|
||||
169.254.0.0/16 1;
|
||||
10.0.0.0/8 1;
|
||||
172.16.0.0/12 1;
|
||||
192.168.0.0/16 1;
|
||||
100.64.0.0/10 1;
|
||||
::1/128 1;
|
||||
fc00::/7 1;
|
||||
fe80::/10 1;
|
||||
}
|
||||
# A configured trusted proxy may have rewritten remote_addr. Require both
|
||||
# the original peer and the validated effective client to be private.
|
||||
geo $remote_addr $archy_management_private_client {
|
||||
default 0;
|
||||
127.0.0.0/8 1;
|
||||
169.254.0.0/16 1;
|
||||
10.0.0.0/8 1;
|
||||
172.16.0.0/12 1;
|
||||
192.168.0.0/16 1;
|
||||
100.64.0.0/10 1;
|
||||
::1/128 1;
|
||||
fc00::/7 1;
|
||||
fe80::/10 1;
|
||||
}
|
||||
map $http_x_archipelago_public_ingress $archy_management_public_ingress {
|
||||
default 1;
|
||||
'' 0;
|
||||
}
|
||||
map "$archy_management_private_source:$archy_management_private_client:$archy_management_public_ingress:$uri" $archy_management_denied {
|
||||
default 1;
|
||||
~^1:1:0: 0;
|
||||
"~^[01]:[01]:[01]:/\\.well-known/acme-challenge/[A-Za-z0-9_-]+$" 0;
|
||||
}
|
||||
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
|
||||
'''
|
||||
CHECK = ' if ($archy_management_denied) { return 404; }\n'
|
||||
|
||||
|
||||
def server_blocks(text):
|
||||
"""Find server blocks without interpreting braces in comments or strings."""
|
||||
masked = list(text)
|
||||
quote = None
|
||||
escaped = False
|
||||
comment = False
|
||||
for i, char in enumerate(text):
|
||||
if comment:
|
||||
if char == '\n':
|
||||
comment = False
|
||||
else:
|
||||
masked[i] = ' '
|
||||
elif escaped:
|
||||
masked[i] = ' '
|
||||
escaped = False
|
||||
elif quote:
|
||||
masked[i] = ' '
|
||||
if char == '\\':
|
||||
escaped = True
|
||||
elif char == quote:
|
||||
quote = None
|
||||
elif char == '#':
|
||||
comment = True
|
||||
masked[i] = ' '
|
||||
elif char in ('"', "'"):
|
||||
quote = char
|
||||
masked[i] = ' '
|
||||
plain = ''.join(masked)
|
||||
for found in re.finditer(r'\bserver\s*\{', plain):
|
||||
opening = found.end() - 1
|
||||
depth = 1
|
||||
end = opening + 1
|
||||
while end < len(plain) and depth:
|
||||
if plain[end] == '{':
|
||||
depth += 1
|
||||
elif plain[end] == '}':
|
||||
depth -= 1
|
||||
end += 1
|
||||
if depth:
|
||||
raise ValueError('Unbalanced nginx server block; configuration left unchanged')
|
||||
yield opening, end, plain[opening + 1:end - 1]
|
||||
|
||||
|
||||
def guarded(text):
|
||||
original = text
|
||||
if text.count(BEGIN) != text.count(END) or text.count(BEGIN) > 1:
|
||||
raise ValueError('Ambiguous managed source guard; configuration left unchanged')
|
||||
if BEGIN in text and text.index(BEGIN) > text.index(END):
|
||||
raise ValueError('Reversed managed source guard markers; configuration left unchanged')
|
||||
text = re.sub(re.escape(BEGIN) + r'.*?' + re.escape(END) + r'\n?', '', text, flags=re.S)
|
||||
edits = []
|
||||
protected = set()
|
||||
for opening, end, body in server_blocks(text):
|
||||
ports = set()
|
||||
# Older node-CA setup used address-specific HTTPS listeners without
|
||||
# default_server. The dashboard's catch-all name still identifies it.
|
||||
management = any('_' in names.split() for names in
|
||||
re.findall(r'\bserver_name\s+([^;]+);', body))
|
||||
for listen in re.findall(r'\blisten\s+([^;]+);', body):
|
||||
tokens = listen.split()
|
||||
if 'default_server' not in tokens and not management:
|
||||
continue
|
||||
match = re.search(r'(?:^|:)(80|443)$', tokens[0])
|
||||
if match:
|
||||
ports.add(int(match[1]))
|
||||
if not ports:
|
||||
continue
|
||||
protected.update(ports)
|
||||
actual = text[opening + 1:end - 1]
|
||||
if CHECK.strip() not in actual:
|
||||
edits.append(opening + 1)
|
||||
if protected != {80, 443}:
|
||||
raise ValueError('Expected both default HTTP and HTTPS dashboard servers; no partial guard installed')
|
||||
for at in reversed(edits):
|
||||
text = text[:at] + '\n' + CHECK + text[at:]
|
||||
text = GUARD + '\n' + text.lstrip('\n')
|
||||
return text if text != original else original
|
||||
|
||||
|
||||
def atomic(path, data, mode):
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
os.fchmod(stream.fileno(), mode)
|
||||
stream.write(data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
try:
|
||||
os.replace(temporary, path)
|
||||
sync_directory(path.parent)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def sync_directory(path):
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def active_dashboard(nginx_root=Path('/etc/nginx')):
|
||||
enabled = nginx_root / 'sites-enabled/archipelago'
|
||||
available = nginx_root / 'sites-available/archipelago'
|
||||
# Installed systems have both symlinks and standalone enabled copies.
|
||||
# Follow a symlink without replacing it; patch the copy when it is active.
|
||||
selected = enabled if enabled.exists() or enabled.is_symlink() else available
|
||||
return selected.resolve(strict=True)
|
||||
|
||||
|
||||
def apply(path, command=subprocess.run, lock_path=Path('/run/lock/archy-nginx-config.lock')):
|
||||
# The NPM bridge uses this same lock for nginx configuration transactions.
|
||||
with lock_path.open('a+b') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
return apply_locked(path.resolve(strict=True), command)
|
||||
|
||||
|
||||
def apply_locked(path, command):
|
||||
old = path.read_bytes()
|
||||
new = guarded(old.decode()).encode()
|
||||
if new == old:
|
||||
return False
|
||||
backup_dir = (Path('/var/lib/archipelago/nginx-management-guard')
|
||||
if path.is_relative_to('/etc/nginx') else path.parent)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
backup = backup_dir / (path.name + '.before-management-guard-' + str(time.time_ns()))
|
||||
# Exclusive, synced backup is a prerequisite to modifying the live config.
|
||||
with backup.open('xb') as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
stream.write(old)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
sync_directory(backup.parent)
|
||||
mode = path.stat().st_mode & 0o777
|
||||
atomic(path, new, mode)
|
||||
try:
|
||||
for args in (['nginx', '-t'], ['systemctl', 'reload', 'nginx']):
|
||||
result = command(args, capture_output=True, timeout=30)
|
||||
if result.returncode:
|
||||
raise RuntimeError('Dashboard source guard validation/reload failed')
|
||||
except Exception as failure:
|
||||
atomic(path, old, mode)
|
||||
# Reload the known previous configuration if a failed reload changed state.
|
||||
for args in (['nginx', '-t'], ['systemctl', 'reload', 'nginx']):
|
||||
result = command(args, capture_output=True, timeout=30)
|
||||
if result.returncode:
|
||||
raise RuntimeError('Previous configuration restored on disk, but rollback validation/reload failed') from failure
|
||||
raise
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--render', action='store_true')
|
||||
parser.add_argument('path', nargs='?')
|
||||
args = parser.parse_args()
|
||||
path = Path(args.path) if args.path else active_dashboard()
|
||||
if args.render:
|
||||
print(guarded(path.read_text()), end='')
|
||||
else:
|
||||
print('Dashboard public source guard installed' if apply(path) else 'Dashboard source guard unchanged')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user