Template
Add Amnezia Web Panel source with PostgreSQL 17 storage.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user