genlanding.py (20989B)
1 #!/usr/bin/env python3 2 """ 3 Configura o Apache (Debian) para servir a landing runv.club: VirtualHost, 4 mod_userdir + mod_rewrite, cópia de site/public para DocumentRoot, redirect 5 www → apex em HTTP. Produção ou modo --dev para testes locais. 6 Metadados SEO: editar site/public/. FAQ estático: public/faq/ (copiado com o resto). 7 Notícias: site/news/publish_news.py gera public/news/data/news.json e feed.rss e, em produção, 8 tenta ``genlanding --sync-public-only`` no fim (DocumentRoot existente). O MIME ``text/xml`` para 9 ``/news/feed.rss`` fica em ``conf-available/runv-landing-rss-mime.conf`` (``a2enconf``), aplicável 10 a :80 e :443 sem editar o vhost SSL do Certbot. 11 12 Executar como root (excepto --dry-run). Apenas biblioteca padrão Python 3. 13 14 Versão 0.08 — runv.club 15 """ 16 17 from __future__ import annotations 18 19 import argparse 20 import grp 21 import json 22 import os 23 import pwd 24 import re 25 import shutil 26 import subprocess 27 import sys 28 import tempfile 29 from pathlib import Path 30 from typing import Final 31 32 VERSION: Final[str] = "0.08" 33 EXIT_OK: Final[int] = 0 34 EXIT_USAGE: Final[int] = 1 35 EXIT_ERROR: Final[int] = 2 36 37 SCRIPT_DIR = Path(__file__).resolve().parent 38 ADMIN_DIR = SCRIPT_DIR.parent / "scripts" / "admin" 39 if str(ADMIN_DIR) not in sys.path: 40 sys.path.insert(0, str(ADMIN_DIR)) 41 42 from admin_guard import ensure_admin_cli 43 44 DEFAULT_SOURCE: Final[Path] = SCRIPT_DIR / "public" 45 DEFAULT_MEMBERS_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") 46 47 PROD_DOMAIN: Final[str] = "runv.club" 48 PROD_DOCUMENT_ROOT: Final[Path] = Path("/var/www/runv.club/html") 49 PROD_SITE_CONF: Final[str] = "runv.club.conf" 50 51 DEV_DOMAIN: Final[str] = "runv.local" 52 DEV_DOCUMENT_ROOT: Final[Path] = Path("/var/www/runv-dev/html") 53 DEV_SITE_CONF: Final[str] = "runv-dev.conf" 54 55 APACHE_SITES_AVAILABLE: Final[Path] = Path("/etc/apache2/sites-available") 56 APACHE_CONF_AVAILABLE: Final[Path] = Path("/etc/apache2/conf-available") 57 # Snippet global: MIME do feed em todos os vhosts (:80 e :443), sem tocar no SSL do Certbot. 58 RSS_MIME_CONF_FILE: Final[str] = "runv-landing-rss-mime.conf" 59 RSS_MIME_CONF_STEM: Final[str] = "runv-landing-rss-mime" 60 APACHE_CTL: Final[str] = "/usr/sbin/apache2ctl" 61 DEFAULT_SITE: Final[str] = "000-default.conf" 62 63 64 def eprint(msg: str) -> None: 65 print(msg, file=sys.stderr) 66 67 68 def require_root(*, dry_run: bool) -> None: 69 if dry_run: 70 return 71 if os.geteuid() != 0: 72 eprint("Erro: execute como root (sudo), excepto com --dry-run.") 73 raise SystemExit(EXIT_USAGE) 74 75 76 def apache_installed() -> bool: 77 return Path(APACHE_CTL).is_file() 78 79 80 def log_tag_from_domain(domain: str) -> str: 81 """Nome seguro para ficheiros de log Apache.""" 82 return re.sub(r"[^\w.-]+", "-", domain).strip("-") or "runv" 83 84 85 def render_rss_mime_conf_contents(document_root: Path) -> str: 86 """Snippet em conf-available: vale para :80 e :443 (evita editar o vhost SSL do Certbot à mão).""" 87 root = document_root.as_posix() 88 return f"""# Gerado por genlanding.py v{VERSION} — runv.club 89 # Chromium descarrega com application/rss+xml (mod_mime por extensão .rss). 90 # RemoveType + Header forçam text/xml na resposta; requer mod_headers (a2enmod headers). 91 # Global ao servidor para o caminho actual do DocumentRoot (volte a correr genlanding se mudar). 92 93 <Directory {root}/news> 94 RemoveType rss 95 <Files "feed.rss"> 96 ForceType text/xml 97 Header set Content-Type "text/xml; charset=utf-8" 98 </Files> 99 </Directory> 100 """ 101 102 103 def render_vhost( 104 *, 105 server_name: str, 106 document_root: Path, 107 log_tag: str, 108 ) -> str: 109 www_alias = f"www.{server_name}" 110 return f"""# Gerado por genlanding.py v{VERSION} — runv.club 111 # Não editar à mão sem saber o que faz; volte a correr o script ou ajuste e recarregue o Apache. 112 # MIME do feed RSS: conf-available/{RSS_MIME_CONF_FILE} (a2enconf {RSS_MIME_CONF_STEM}). 113 114 <VirtualHost *:80> 115 ServerName {server_name} 116 ServerAlias {www_alias} 117 DocumentRoot {document_root} 118 119 # Redirect www → apex (HTTP; após Certbot o bloco :80 pode ser actualizado pelo certbot) 120 RewriteEngine On 121 RewriteCond %{{HTTP_HOST}} ^www\\.(.+)$ [NC] 122 RewriteRule ^ http://%1%{{REQUEST_URI}} [R=301,L] 123 124 <Directory {document_root}> 125 Options FollowSymLinks 126 AllowOverride None 127 Require all granted 128 </Directory> 129 130 ErrorLog ${{APACHE_LOG_DIR}}/{log_tag}-error.log 131 CustomLog ${{APACHE_LOG_DIR}}/{log_tag}-access.log combined 132 </VirtualHost> 133 """ 134 135 136 def run_cmd( 137 cmd: list[str], 138 *, 139 dry_run: bool, 140 verbose: bool = True, 141 ) -> None: 142 if verbose: 143 print(f" $ {' '.join(cmd)}") 144 if dry_run: 145 return 146 r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) 147 if r.returncode != 0: 148 err = (r.stderr or r.stdout or "").strip() 149 raise RuntimeError(f"Comando falhou ({r.returncode}): {' '.join(cmd)}\n{err}") 150 151 152 def run_cmd_allow_fail( 153 cmd: list[str], 154 *, 155 dry_run: bool, 156 ok_hint: str = "", 157 ) -> None: 158 print(f" $ {' '.join(cmd)}") 159 if dry_run: 160 return 161 r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) 162 if r.returncode == 0: 163 print(f" [ok] {' '.join(cmd)}") 164 else: 165 msg = (r.stderr or r.stdout or "").strip() or ok_hint 166 print(f" [info] {' '.join(cmd)} — {msg or 'ignorado (já inactivo?)'}") 167 168 169 def copy_landing(source: Path, dest: Path, *, dry_run: bool) -> None: 170 if not source.is_dir(): 171 raise FileNotFoundError(f"Pasta origem inexistente: {source}") 172 if dry_run: 173 print(f" [dry-run] copiaria {source} -> {dest}") 174 return 175 dest.parent.mkdir(parents=True, exist_ok=True) 176 if dest.exists(): 177 shutil.rmtree(dest) 178 shutil.copytree(source, dest) 179 180 181 def preserve_existing_members_json(document_root: Path, *, dry_run: bool) -> Path | None: 182 """Guarda uma cópia temporária do members.json actual para rollback seguro da constelação.""" 183 current = document_root / "data" / "members.json" 184 if dry_run or not current.is_file(): 185 return None 186 fd, tmp_name = tempfile.mkstemp(prefix="runv-members-backup-", suffix=".json") 187 os.close(fd) 188 backup = Path(tmp_name) 189 shutil.copy2(current, backup) 190 return backup 191 192 193 def restore_members_json_backup( 194 document_root: Path, 195 backup: Path | None, 196 *, 197 dry_run: bool, 198 ) -> bool: 199 """Restaura o members.json anterior se existir backup.""" 200 if dry_run or backup is None or not backup.is_file(): 201 return False 202 out = document_root / "data" / "members.json" 203 out.parent.mkdir(parents=True, exist_ok=True) 204 shutil.copy2(backup, out) 205 return True 206 207 208 def cleanup_members_json_backup(backup: Path | None) -> None: 209 if backup is None: 210 return 211 backup.unlink(missing_ok=True) 212 213 214 def refresh_members_json_in_document_root( 215 document_root: Path, 216 *, 217 users_json: Path, 218 homes_root: Path | None, 219 dry_run: bool, 220 ) -> bool: 221 """Regenera data/members.json no DocumentRoot após copiar site/public (stdlib).""" 222 if dry_run: 223 print( 224 " [dry-run] regeneraria data/members.json " 225 f"({users_json} → {document_root / 'data' / 'members.json'})", 226 ) 227 return True 228 if not document_root.is_dir(): 229 eprint( 230 f"Erro: DocumentRoot inexistente ({document_root}); não é possível gravar data/members.json." 231 ) 232 return False 233 script = SCRIPT_DIR / "build_directory.py" 234 if not script.is_file(): 235 eprint(f"Aviso: {script} não encontrado; members.json não regenerado.") 236 return False 237 out = document_root / "data" / "members.json" 238 cmd = [ 239 sys.executable, 240 str(script), 241 "--users-json", 242 str(users_json), 243 "-o", 244 str(out), 245 ] 246 if homes_root is not None: 247 cmd.extend(["--homes-root", str(homes_root)]) 248 print(f" $ {' '.join(cmd)}") 249 r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) 250 if r.returncode != 0: 251 tail = (r.stderr or r.stdout or "").strip() 252 eprint( 253 f"Aviso: build_directory.py terminou com código {r.returncode}; " 254 f"members.json pode estar desactualizado. {tail[:800]}" 255 ) 256 return False 257 else: 258 print(f" [ok] members.json em {out}") 259 if r.stderr.strip(): 260 for line in r.stderr.strip().splitlines()[:5]: 261 print(f" {line}") 262 try: 263 data = json.loads(out.read_text(encoding="utf-8")) 264 if isinstance(data, list): 265 print( 266 f" [ok] constelação (bolhas): {len(data)} membro(s) — " 267 "o index.html faz fetch a data/members.json (relativo ao DocumentRoot)." 268 ) 269 else: 270 eprint("Aviso: members.json não é uma lista JSON; verifique build_directory.py.") 271 return False 272 except (OSError, json.JSONDecodeError, TypeError) as e: 273 eprint(f"Aviso: não foi possível confirmar o conteúdo de members.json: {e}") 274 return False 275 return True 276 277 278 def chown_www_data(path: Path, *, dry_run: bool) -> None: 279 if dry_run: 280 print(f" [dry-run] chown -R www-data:www-data {path}") 281 return 282 try: 283 u = pwd.getpwnam("www-data") 284 g = grp.getgrnam("www-data") 285 except KeyError as e: 286 raise RuntimeError("Utilizador ou grupo 'www-data' não encontrado.") from e 287 run_cmd( 288 ["chown", "-R", f"{u.pw_uid}:{g.gr_gid}", str(path)], 289 dry_run=False, 290 verbose=True, 291 ) 292 293 294 def parse_args(argv: list[str] | None) -> argparse.Namespace: 295 p = argparse.ArgumentParser( 296 description="Configura Apache para a landing runv (VirtualHost, userdir, cópia de public/).", 297 ) 298 p.add_argument( 299 "--source", 300 type=Path, 301 default=DEFAULT_SOURCE, 302 help=f"pasta com a landing (default: {DEFAULT_SOURCE})", 303 ) 304 p.add_argument( 305 "--document-root", 306 type=Path, 307 default=None, 308 help="DocumentRoot do VirtualHost (default: prod ou dev conforme --dev)", 309 ) 310 p.add_argument( 311 "--domain", 312 type=str, 313 default=None, 314 help="ServerName (default: runv.club ou runv.local com --dev)", 315 ) 316 p.add_argument( 317 "--dev", 318 action="store_true", 319 help="modo teste local: runv.local, runv-dev.conf, não desactiva 000-default", 320 ) 321 p.add_argument("--dry-run", action="store_true", help="mostra acções sem alterar o sistema") 322 p.add_argument( 323 "--certbot", 324 action="store_true", 325 help="executa certbot --apache após configurar HTTP (incompatível com --dev)", 326 ) 327 p.add_argument( 328 "--keep-default-site", 329 action="store_true", 330 help="não desactiva 000-default.conf (produção e --dev: mantém página Debian; pedidos por IP não casam com ServerName)", 331 ) 332 p.add_argument( 333 "--sync-public-only", 334 action="store_true", 335 help=( 336 "só copia site/public → DocumentRoot, chown www-data e regenera data/members.json; " 337 "não configura Apache nem recarrega o serviço (uso típico: após create_runv_user.py)" 338 ), 339 ) 340 p.add_argument( 341 "--no-refresh-members", 342 action="store_true", 343 help="não executar site/build_directory.py após copiar public/ (omitir data/members.json)", 344 ) 345 p.add_argument( 346 "--members-users-json", 347 type=Path, 348 default=DEFAULT_MEMBERS_USERS_JSON, 349 help=f"fonte para build_directory.py (default: {DEFAULT_MEMBERS_USERS_JSON})", 350 ) 351 p.add_argument( 352 "--members-homes-root", 353 type=Path, 354 default=None, 355 help="opcional: --homes-root para build_directory.py (ex. /home)", 356 ) 357 p.add_argument("--version", action="version", version=f"%(prog)s {VERSION} — runv.club") 358 return p.parse_args(argv) 359 360 361 def resolve_profile(args: argparse.Namespace) -> tuple[str, Path, str, bool]: 362 """ 363 Retorna (domain, document_root, site_conf_filename, disable_default_site). 364 """ 365 if args.dev: 366 domain = (args.domain or DEV_DOMAIN).strip().lower() 367 doc = args.document_root or DEV_DOCUMENT_ROOT 368 conf = DEV_SITE_CONF 369 else: 370 domain = (args.domain or PROD_DOMAIN).strip().lower() 371 doc = args.document_root or PROD_DOCUMENT_ROOT 372 conf = PROD_SITE_CONF 373 # Mesma regra em prod e --dev: sem --keep-default-site, desactiva 000-default para que 374 # pedidos por IP (Host sem match) caiam no vhost runv em vez da página Debian. 375 disable_default = not args.keep_default_site 376 return domain, doc.resolve(), conf, disable_default 377 378 379 def sync_public_only_main(args: argparse.Namespace) -> int: 380 """Copia site/public → DocumentRoot, chown e members.json; sem Apache.""" 381 _, document_root, _, _ = resolve_profile(args) 382 source = args.source.resolve() 383 384 print(f"== genlanding.py v{VERSION} — sync-public-only ==") 385 print(f" modo: {'dev' if args.dev else 'produção'}") 386 print(f" DocumentRoot: {document_root}") 387 print(f" origem: {source}") 388 print() 389 390 members_backup = preserve_existing_members_json(document_root, dry_run=args.dry_run) 391 try: 392 copy_landing(source, document_root, dry_run=args.dry_run) 393 if not args.dry_run: 394 chown_www_data(document_root, dry_run=False) 395 396 if not args.no_refresh_members: 397 refreshed = refresh_members_json_in_document_root( 398 document_root, 399 users_json=args.members_users_json, 400 homes_root=args.members_homes_root.resolve() 401 if args.members_homes_root 402 else None, 403 dry_run=args.dry_run, 404 ) 405 if not refreshed and restore_members_json_backup( 406 document_root, 407 members_backup, 408 dry_run=args.dry_run, 409 ): 410 print(" [ok] members.json anterior restaurado; constelação preservada.") 411 elif restore_members_json_backup(document_root, members_backup, dry_run=args.dry_run): 412 print(" [ok] members.json anterior preservado (--no-refresh-members).") 413 except (FileNotFoundError, OSError, RuntimeError) as e: 414 eprint(f"Erro: {e}") 415 cleanup_members_json_backup(members_backup) 416 return EXIT_ERROR 417 cleanup_members_json_backup(members_backup) 418 419 print() 420 print(" [ok] sync-public-only concluído (Apache não foi alterado).") 421 return EXIT_OK 422 423 424 def main(argv: list[str] | None = None) -> int: 425 args = parse_args(argv) 426 ensure_admin_cli( 427 script_name=Path(__file__).name, 428 dry_run=bool(args.dry_run), 429 ) 430 431 if args.dev and args.certbot: 432 eprint("Erro: --certbot não pode ser usado com --dev (Certbot não serve para domínios locais).") 433 return EXIT_USAGE 434 435 if args.sync_public_only and args.certbot: 436 eprint("Erro: --certbot não pode ser usado com --sync-public-only.") 437 return EXIT_USAGE 438 439 require_root(dry_run=args.dry_run) 440 441 if args.sync_public_only: 442 return sync_public_only_main(args) 443 444 domain, document_root, site_conf_name, disable_default = resolve_profile(args) 445 source = args.source.resolve() 446 conf_path = APACHE_SITES_AVAILABLE / site_conf_name 447 log_tag = log_tag_from_domain(domain) 448 449 print(f"== genlanding.py v{VERSION} — runv.club ==") 450 print(f" modo: {'dev' if args.dev else 'produção'}") 451 print(f" ServerName: {domain}") 452 print(f" DocumentRoot: {document_root}") 453 print(f" ficheiro site: {conf_path}") 454 print(f" origem: {source}") 455 print() 456 457 members_backup = preserve_existing_members_json(document_root, dry_run=args.dry_run) 458 if not apache_installed(): 459 eprint("Erro: Apache não parece instalado (falta /usr/sbin/apache2ctl).") 460 eprint(" Instale com: sudo apt install -y apache2") 461 eprint(" ou corra scripts/admin/starthere.py antes.") 462 cleanup_members_json_backup(members_backup) 463 return EXIT_ERROR 464 465 vhost_body = render_vhost( 466 server_name=domain, 467 document_root=document_root, 468 log_tag=log_tag, 469 ) 470 471 try: 472 if args.dry_run: 473 print("--- VirtualHost (pré-visualização) ---") 474 print(vhost_body) 475 476 run_cmd(["a2enmod", "userdir"], dry_run=args.dry_run) 477 run_cmd(["a2enmod", "rewrite"], dry_run=args.dry_run) 478 run_cmd(["a2enmod", "headers"], dry_run=args.dry_run) 479 480 rss_conf_path = APACHE_CONF_AVAILABLE / RSS_MIME_CONF_FILE 481 rss_body = render_rss_mime_conf_contents(document_root) 482 if args.dry_run: 483 print("--- conf-available (RSS MIME, :80 e :443) ---") 484 print(rss_body) 485 print(f" [dry-run] escreveria {rss_conf_path} ; a2enconf {RSS_MIME_CONF_STEM}") 486 else: 487 if not APACHE_CONF_AVAILABLE.is_dir(): 488 APACHE_CONF_AVAILABLE.mkdir(parents=True, exist_ok=True) 489 rss_conf_path.write_text(rss_body, encoding="utf-8") 490 os.chmod(rss_conf_path, 0o644) 491 print(f" [ok] RSS MIME: {rss_conf_path}") 492 run_cmd_allow_fail( 493 ["a2enconf", RSS_MIME_CONF_STEM], 494 dry_run=args.dry_run, 495 ok_hint="conf já activo", 496 ) 497 498 copy_landing(source, document_root, dry_run=args.dry_run) 499 if not args.dry_run: 500 chown_www_data(document_root, dry_run=False) 501 502 if not args.no_refresh_members: 503 refreshed = refresh_members_json_in_document_root( 504 document_root, 505 users_json=args.members_users_json, 506 homes_root=args.members_homes_root.resolve() 507 if args.members_homes_root 508 else None, 509 dry_run=args.dry_run, 510 ) 511 if not refreshed and restore_members_json_backup( 512 document_root, 513 members_backup, 514 dry_run=args.dry_run, 515 ): 516 print(" [ok] members.json anterior restaurado; constelação preservada.") 517 elif restore_members_json_backup(document_root, members_backup, dry_run=args.dry_run): 518 print(" [ok] members.json anterior preservado (--no-refresh-members).") 519 520 if args.dry_run: 521 print(f" [dry-run] escreveria {conf_path}") 522 else: 523 conf_path.write_text(vhost_body, encoding="utf-8") 524 os.chmod(conf_path, 0o644) 525 print(f" [ok] VirtualHost em {conf_path}") 526 527 if disable_default: 528 run_cmd_allow_fail( 529 ["a2dissite", DEFAULT_SITE], 530 dry_run=args.dry_run, 531 ok_hint="site por defeito já estava desactivado", 532 ) 533 else: 534 print(" [info] site por defeito 000-default mantido activo.") 535 536 run_cmd(["a2ensite", site_conf_name], dry_run=args.dry_run) 537 538 run_cmd([APACHE_CTL, "configtest"], dry_run=args.dry_run) 539 540 if not args.dry_run: 541 subprocess.run( 542 ["systemctl", "reload", "apache2"], 543 check=True, 544 timeout=60, 545 ) 546 else: 547 print(" [dry-run] systemctl reload apache2") 548 print(" [ok] Apache recarregado.") 549 550 if args.certbot: 551 www = f"www.{domain}" 552 print() 553 certbot_bin = shutil.which("certbot") 554 if not certbot_bin: 555 eprint("Erro: certbot não encontrado no PATH. Instale: sudo apt install -y certbot python3-certbot-apache") 556 return EXIT_ERROR 557 print(" A executar Certbot (interactivo se necessário)...") 558 if args.dry_run: 559 print(f" [dry-run] {certbot_bin} --apache -d {domain} -d {www}") 560 else: 561 r = subprocess.run( 562 [certbot_bin, "--apache", "-d", domain, "-d", www], 563 check=False, 564 ) 565 if r.returncode != 0: 566 eprint("Aviso: certbot terminou com código != 0; verifique TLS manualmente.") 567 return EXIT_ERROR 568 print(" [ok] Certbot concluído.") 569 570 except (FileNotFoundError, OSError, RuntimeError) as e: 571 eprint(f"Erro: {e}") 572 cleanup_members_json_backup(members_backup) 573 return EXIT_ERROR 574 cleanup_members_json_backup(members_backup) 575 576 print() 577 print("Próximos passos:") 578 print(f" - Testar: curl -sI http://{domain}/ | head -5") 579 if args.dev: 580 print(" - Em /etc/hosts (cliente ou VM): 127.0.0.1 runv.local www.runv.local") 581 print( 582 " - Membros na constelação: regenerado com build_directory após esta cópia " 583 "(fonte: /var/lib/runv/users.json). Novas contas: create_runv_user.py corre " 584 "genlanding.py --sync-public-only (public + members). Use --no-refresh-members para omitir." 585 ) 586 return EXIT_OK 587 588 589 if __name__ == "__main__": 590 raise SystemExit(main())