Template
384 lines
14 KiB
Python
384 lines
14 KiB
Python
"""
|
|
NGINX Web Server Manager.
|
|
|
|
Runs an NGINX container with a single editable site (index.html) and a
|
|
Let's Encrypt certificate issued through the webroot HTTP-01 challenge.
|
|
The panel owns:
|
|
- nginx.conf: editable through the Config button
|
|
- index.html: editable through the Site button
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import shlex
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _q(value):
|
|
return shlex.quote(str(value))
|
|
|
|
|
|
class NginxManager:
|
|
PROTOCOL = 'nginx'
|
|
CONTAINER_NAME = 'amnezia-nginx'
|
|
IMAGE_NAME = 'nginx:alpine'
|
|
CERTBOT_IMAGE = 'certbot/certbot:latest'
|
|
CERTBOT_CONTAINER_NAME = 'amnezia-nginx-certbot'
|
|
BASE_DIR = '/opt/amnezia/nginx'
|
|
CONF_DIR = f'{BASE_DIR}/conf'
|
|
HTML_DIR = f'{BASE_DIR}/html'
|
|
CERTBOT_WWW_DIR = f'{BASE_DIR}/certbot/www'
|
|
LETSENCRYPT_DIR = f'{BASE_DIR}/letsencrypt'
|
|
META_PATH = f'{BASE_DIR}/metadata.json'
|
|
NGINX_CONF_PATH = f'{CONF_DIR}/default.conf'
|
|
INDEX_PATH = f'{HTML_DIR}/index.html'
|
|
DEFAULT_PORT = 443
|
|
|
|
def __init__(self, ssh, protocol='nginx'):
|
|
self.ssh = ssh
|
|
self.protocol = protocol or self.PROTOCOL
|
|
|
|
# ===================== STATUS =====================
|
|
|
|
def check_docker_installed(self):
|
|
out, _, code = self.ssh.run_command("docker --version 2>/dev/null")
|
|
if code != 0:
|
|
return False
|
|
out2, _, _ = self.ssh.run_command(
|
|
"systemctl is-active docker 2>/dev/null || service docker status 2>/dev/null"
|
|
)
|
|
return 'active' in out2 or 'running' in out2.lower()
|
|
|
|
def check_protocol_installed(self, protocol_type='nginx'):
|
|
out, _, _ = self.ssh.run_sudo_command(
|
|
f"docker ps -a --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Names}}}}'"
|
|
)
|
|
return self.CONTAINER_NAME in out.strip().split('\n')
|
|
|
|
def check_container_running(self, protocol_type='nginx'):
|
|
out, _, _ = self.ssh.run_sudo_command(
|
|
f"docker ps --filter name=^{self.CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
|
)
|
|
return 'Up' in out
|
|
|
|
def check_certbot_running(self):
|
|
out, _, _ = self.ssh.run_sudo_command(
|
|
f"docker ps --filter name=^{self.CERTBOT_CONTAINER_NAME}$ --format '{{{{.Status}}}}'"
|
|
)
|
|
return 'Up' in out
|
|
|
|
def get_server_status(self, protocol_type='nginx'):
|
|
exists = self.check_protocol_installed(protocol_type)
|
|
running = self.check_container_running(protocol_type)
|
|
meta = self._read_metadata() if exists else {}
|
|
config = self._get_server_config(protocol_type) if exists else ''
|
|
domain = meta.get('domain') or self._parse_domain(config)
|
|
port = int(meta.get('port') or self.DEFAULT_PORT)
|
|
return {
|
|
'container_exists': exists,
|
|
'container_running': running,
|
|
'port': port,
|
|
'domain': domain,
|
|
'email': meta.get('email'),
|
|
'site_url': self._site_url(domain, port) if domain else None,
|
|
'protocol': protocol_type,
|
|
'base_protocol': self.PROTOCOL,
|
|
'instance': 1,
|
|
'container_name': self.CONTAINER_NAME,
|
|
'certbot_container_name': self.CERTBOT_CONTAINER_NAME,
|
|
'certbot_running': self.check_certbot_running() if exists else False,
|
|
}
|
|
|
|
# ===================== CONFIG BUILDERS =====================
|
|
|
|
def _validate_domain(self, domain):
|
|
domain = (domain or '').strip().lower()
|
|
if not domain or len(domain) > 253:
|
|
raise ValueError('Domain is required')
|
|
if not re.match(r'^(?!-)[a-z0-9.-]+(?<!-)$', domain) or '.' not in domain:
|
|
raise ValueError('Invalid domain name')
|
|
return domain
|
|
|
|
def _validate_email(self, email):
|
|
email = (email or '').strip()
|
|
if not email or '@' not in email:
|
|
raise ValueError('Valid Let\'s Encrypt email is required')
|
|
return email
|
|
|
|
def _site_url(self, domain, port):
|
|
if not domain:
|
|
return None
|
|
port = int(port or self.DEFAULT_PORT)
|
|
return f'https://{domain}' if port == 443 else f'https://{domain}:{port}'
|
|
|
|
def _build_initial_config(self, domain):
|
|
return f"""server {{
|
|
listen 80;
|
|
server_name {domain};
|
|
|
|
location /.well-known/acme-challenge/ {{
|
|
root /var/www/certbot;
|
|
}}
|
|
|
|
location / {{
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
try_files $uri $uri/ /index.html;
|
|
}}
|
|
}}
|
|
"""
|
|
|
|
def _build_ssl_config(self, domain, port):
|
|
redirect_target = 'https://$host$request_uri' if int(port) == 443 else f'https://$host:{int(port)}$request_uri'
|
|
return f"""server {{
|
|
listen 80;
|
|
server_name {domain};
|
|
|
|
location /.well-known/acme-challenge/ {{
|
|
root /var/www/certbot;
|
|
}}
|
|
|
|
location / {{
|
|
return 301 {redirect_target};
|
|
}}
|
|
}}
|
|
|
|
server {{
|
|
listen 443 ssl http2;
|
|
server_name {domain};
|
|
|
|
ssl_certificate /etc/letsencrypt/live/{domain}/fullchain.pem;
|
|
ssl_certificate_key /etc/letsencrypt/live/{domain}/privkey.pem;
|
|
ssl_session_timeout 1d;
|
|
ssl_session_cache shared:SSL:10m;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
ssl_prefer_server_ciphers off;
|
|
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
location / {{
|
|
try_files $uri $uri/ /index.html;
|
|
}}
|
|
}}
|
|
"""
|
|
|
|
def _default_index(self, domain):
|
|
return f"""<!doctype html>
|
|
<html lang=\"en\">
|
|
<head>
|
|
<meta charset=\"utf-8\">
|
|
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
|
|
<title>{domain}</title>
|
|
<style>
|
|
body {{ margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: system-ui, -apple-system, Segoe UI, sans-serif; background: #0f172a; color: #e2e8f0; }}
|
|
main {{ text-align: center; padding: 32px; }}
|
|
h1 {{ font-size: clamp(2rem, 6vw, 4rem); margin: 0 0 12px; }}
|
|
p {{ color: #94a3b8; font-size: 1.1rem; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>NGINX is running</h1>
|
|
<p>{domain}</p>
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
def _parse_domain(self, config_text):
|
|
m = re.search(r'^\s*server_name\s+([^;\s]+)', config_text or '', re.MULTILINE)
|
|
return m.group(1) if m else None
|
|
|
|
# ===================== FILE I/O =====================
|
|
|
|
def _read_file(self, path):
|
|
out, _, code = self.ssh.run_sudo_command(f"cat {_q(path)} 2>/dev/null")
|
|
return out if code == 0 else ''
|
|
|
|
def _write_file(self, path, content):
|
|
parent = path.rsplit('/', 1)[0]
|
|
self.ssh.run_sudo_command(f"mkdir -p {_q(parent)}")
|
|
self.ssh.upload_file_sudo(content, path)
|
|
|
|
def _read_metadata(self):
|
|
raw = self._read_file(self.META_PATH)
|
|
if not raw.strip():
|
|
return {}
|
|
try:
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return {}
|
|
|
|
def _write_metadata(self, meta):
|
|
self._write_file(self.META_PATH, json.dumps(meta, indent=2, ensure_ascii=False))
|
|
|
|
def _get_server_config(self, protocol_type='nginx'):
|
|
config = self._read_file(self.NGINX_CONF_PATH)
|
|
if config:
|
|
return config
|
|
out, _, code = self.ssh.run_sudo_command(
|
|
f"docker exec {self.CONTAINER_NAME} cat /etc/nginx/conf.d/default.conf 2>/dev/null"
|
|
)
|
|
return out if code == 0 else ''
|
|
|
|
def save_server_config(self, protocol_type='nginx', config_text=''):
|
|
old_config = self._get_server_config(protocol_type)
|
|
self._write_file(self.NGINX_CONF_PATH, config_text or '')
|
|
if self.check_container_running(protocol_type):
|
|
_, err, code = self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -t", timeout=30)
|
|
if code != 0:
|
|
self._write_file(self.NGINX_CONF_PATH, old_config)
|
|
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload 2>/dev/null || true")
|
|
raise RuntimeError(f'Invalid NGINX config: {err}')
|
|
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload")
|
|
return True
|
|
|
|
def get_site_index(self, protocol_type='nginx'):
|
|
return self._read_file(self.INDEX_PATH)
|
|
|
|
def save_site_index(self, protocol_type='nginx', html=''):
|
|
self._write_file(self.INDEX_PATH, html or '')
|
|
return True
|
|
|
|
# ===================== INSTALL / REMOVE =====================
|
|
|
|
def _run_nginx_container(self, port):
|
|
run_cmd = (
|
|
f"docker run -d --restart always "
|
|
f"--name {self.CONTAINER_NAME} "
|
|
f"-p 80:80/tcp "
|
|
f"-p {int(port)}:443/tcp "
|
|
f"-v {self.CONF_DIR}:/etc/nginx/conf.d:ro "
|
|
f"-v {self.HTML_DIR}:/usr/share/nginx/html:ro "
|
|
f"-v {self.CERTBOT_WWW_DIR}:/var/www/certbot:ro "
|
|
f"-v {self.LETSENCRYPT_DIR}:/etc/letsencrypt:ro "
|
|
f"{self.IMAGE_NAME}"
|
|
)
|
|
return self.ssh.run_sudo_command(run_cmd, timeout=60)
|
|
|
|
def _run_certbot_renewer(self, domain, email):
|
|
loop_script = f"""
|
|
set -eu
|
|
if [ ! -f /etc/letsencrypt/live/{domain}/fullchain.pem ]; then
|
|
certbot certonly --webroot --webroot-path /var/www/certbot --email {_q(email)} --agree-tos --no-eff-email -d {_q(domain)}
|
|
fi
|
|
kill -HUP 1 2>/dev/null || true
|
|
while :; do
|
|
certbot renew --webroot --webroot-path /var/www/certbot --quiet --deploy-hook 'kill -HUP 1 2>/dev/null || true'
|
|
sleep 12h & wait $!
|
|
done
|
|
"""
|
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true")
|
|
run_cmd = (
|
|
f"docker run -d --restart always "
|
|
f"--name {self.CERTBOT_CONTAINER_NAME} "
|
|
f"--pid=container:{self.CONTAINER_NAME} "
|
|
f"-v {self.CERTBOT_WWW_DIR}:/var/www/certbot "
|
|
f"-v {self.LETSENCRYPT_DIR}:/etc/letsencrypt "
|
|
f"--entrypoint /bin/sh "
|
|
f"{self.CERTBOT_IMAGE} -c {_q(loop_script)}"
|
|
)
|
|
return self.ssh.run_sudo_command(run_cmd, timeout=60)
|
|
|
|
def _wait_for_certificate(self, domain):
|
|
wait_script = f"""
|
|
for i in $(seq 1 60); do
|
|
if [ -s {self.LETSENCRYPT_DIR}/live/{domain}/fullchain.pem ] && [ -s {self.LETSENCRYPT_DIR}/live/{domain}/privkey.pem ]; then
|
|
exit 0
|
|
fi
|
|
if ! docker ps --filter name=^{self.CERTBOT_CONTAINER_NAME}$ --format '{{{{.Names}}}}' | grep -qx {self.CERTBOT_CONTAINER_NAME}; then
|
|
docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>&1 || true
|
|
exit 1
|
|
fi
|
|
sleep 5
|
|
done
|
|
docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>&1 || true
|
|
exit 1
|
|
"""
|
|
return self.ssh.run_sudo_command(f"sh -c {_q(wait_script)}", timeout=330)
|
|
|
|
def install_protocol(self, protocol_type='nginx', port=None, email=None, domain=None):
|
|
if not self.check_docker_installed():
|
|
return {'status': 'error', 'message': 'Docker not installed'}
|
|
|
|
port = int(port or self.DEFAULT_PORT)
|
|
if port == 80:
|
|
return {'status': 'error', 'message': 'Port 80 is reserved for Let\'s Encrypt validation'}
|
|
domain = self._validate_domain(domain)
|
|
email = self._validate_email(email)
|
|
|
|
self.ssh.run_sudo_command(f"docker pull {self.IMAGE_NAME}", timeout=180)
|
|
self.ssh.run_sudo_command(f"docker pull {self.CERTBOT_IMAGE}", timeout=180)
|
|
|
|
if self.check_protocol_installed(protocol_type):
|
|
self.remove_container(protocol_type)
|
|
|
|
self.ssh.run_sudo_command(
|
|
f"mkdir -p {self.CONF_DIR} {self.HTML_DIR} {self.CERTBOT_WWW_DIR} {self.LETSENCRYPT_DIR}"
|
|
)
|
|
self._write_file(self.NGINX_CONF_PATH, self._build_initial_config(domain))
|
|
self._write_file(self.INDEX_PATH, self._default_index(domain))
|
|
self._write_metadata({'domain': domain, 'email': email, 'port': port})
|
|
|
|
_, err, code = self._run_nginx_container(port)
|
|
if code != 0:
|
|
return {'status': 'error', 'message': f'Failed to start NGINX container: {err}'}
|
|
|
|
_, err, code = self._run_certbot_renewer(domain, email)
|
|
if code != 0:
|
|
self.ssh.run_sudo_command(f"docker logs --tail 100 {self.CONTAINER_NAME} 2>/dev/null || true")
|
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
|
return {'status': 'error', 'message': f'Failed to start certbot renewer container: {err}'}
|
|
|
|
out, err, code = self._wait_for_certificate(domain)
|
|
if code != 0:
|
|
self.ssh.run_sudo_command(f"docker logs --tail 120 {self.CERTBOT_CONTAINER_NAME} 2>/dev/null || true")
|
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true")
|
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
|
return {
|
|
'status': 'error',
|
|
'message': f"Let's Encrypt certificate issue failed. Make sure {domain} points to this server and port 80 is reachable. {err or out}",
|
|
}
|
|
|
|
self._write_file(self.NGINX_CONF_PATH, self._build_ssl_config(domain, port))
|
|
_, err, code = self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -t", timeout=30)
|
|
if code != 0:
|
|
return {'status': 'error', 'message': f'Generated NGINX config is invalid: {err}'}
|
|
self.ssh.run_sudo_command(f"docker exec {self.CONTAINER_NAME} nginx -s reload")
|
|
|
|
url = self._site_url(domain, port)
|
|
return {
|
|
'status': 'success',
|
|
'protocol': protocol_type,
|
|
'base_protocol': self.PROTOCOL,
|
|
'instance': 1,
|
|
'container_name': self.CONTAINER_NAME,
|
|
'certbot_container_name': self.CERTBOT_CONTAINER_NAME,
|
|
'certbot_running': self.check_certbot_running(),
|
|
'port': port,
|
|
'domain': domain,
|
|
'email': email,
|
|
'site_url': url,
|
|
'message': 'NGINX web server installed',
|
|
'log': [
|
|
f'NGINX HTTPS port: {port}/TCP',
|
|
'Port 80/TCP is used for Let\'s Encrypt HTTP-01 validation',
|
|
f'Certificate issued for {domain}',
|
|
f'Auto-renewal container: {self.CERTBOT_CONTAINER_NAME}',
|
|
f'Site URL: {url}',
|
|
],
|
|
}
|
|
|
|
def remove_container(self, protocol_type='nginx'):
|
|
self.ssh.run_sudo_command(f"docker stop {self.CERTBOT_CONTAINER_NAME} || true")
|
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CERTBOT_CONTAINER_NAME} || true")
|
|
self.ssh.run_sudo_command(f"docker stop {self.CONTAINER_NAME} || true")
|
|
self.ssh.run_sudo_command(f"docker rm -fv {self.CONTAINER_NAME} || true")
|
|
self.ssh.run_sudo_command(f"rm -rf {self.BASE_DIR}")
|
|
return True
|