setup_alt_protocols.py (46312B)
1 #!/usr/bin/env python3 2 """ 3 Infraestrutura Gopher (gophernicus) e Gemini (molly-brown) para runv.club. 4 5 - Gopher: raiz em /var/gopher, espaços de utilizador em ~/public_gopher (gophermap). 6 - Gemini: DocBase /var/gemini; **bind mount** ``/var/gemini/users/<user>`` <- ``~/public_gemini`` 7 (o Molly Debian recusa symlinks cujo destino fica fora do DocBase). 8 9 Idempotente, dry-run, subprocess sem shell. Executar como root no Debian. 10 11 Versão 0.14 — runv.club 12 """ 13 14 from __future__ import annotations 15 16 import argparse 17 import grp 18 import importlib.util 19 import logging 20 import shutil 21 import os 22 import pwd 23 import re 24 import stat 25 import subprocess 26 import sys 27 import time 28 from datetime import datetime, timezone 29 from pathlib import Path 30 from typing import Any, Final 31 32 _SCRIPT_DIR = Path(__file__).resolve().parent 33 if str(_SCRIPT_DIR) not in sys.path: 34 sys.path.insert(0, str(_SCRIPT_DIR)) 35 36 from admin_guard import ensure_admin_cli 37 38 # constantes 39 VERSION: Final[str] = "0.14" 40 41 LETSENCRYPT_LIVE: Final[Path] = Path("/etc/letsencrypt/live") 42 LETSENCRYPT_ARCHIVE: Final[Path] = Path("/etc/letsencrypt/archive") 43 SSL_CERT_GROUP: Final[str] = "ssl-cert" 44 45 DEFAULT_USERS_JSON: Final[Path] = Path("/var/lib/runv/users.json") 46 DEFAULT_HOMES_ROOT: Final[Path] = Path("/home") 47 DEFAULT_GEMINI_HOSTNAME: Final[str] = "runv.club" 48 DEFAULT_LE_CERT: Final[Path] = Path("/etc/letsencrypt/live/runv.club/fullchain.pem") 49 DEFAULT_LE_KEY: Final[Path] = Path("/etc/letsencrypt/live/runv.club/privkey.pem") 50 51 GOPHER_ROOT: Final[Path] = Path("/var/gopher") 52 GEMINI_ROOT: Final[Path] = Path("/var/gemini") 53 GEMINI_USERS: Final[Path] = GEMINI_ROOT / "users" 54 FSTAB_PATH: Final[Path] = Path("/etc/fstab") 55 56 # Linha fstab: <source> <mountpoint> none bind 0 0 (paths sem espaços no 2.º campo) 57 _GEMINI_BIND_FSTAB_RE: Final[re.Pattern[str]] = re.compile( 58 r"^(.+)\s+(/var/gemini/users/\S+)\s+none\s+bind\s+0\s+0\s*\Z" 59 ) 60 61 GOPHER_DEFAULT_PATH: Final[Path] = Path("/etc/default/gophernicus") 62 GOPHER_SYSTEMD_SERVICE: Final[Path] = Path("/lib/systemd/system/gophernicus@.service") 63 MOLLY_CONF_DIR: Final[Path] = Path("/etc/molly-brown") 64 MOLLY_INSTANCE: Final[str] = "runv.club" # molly-brown@runv.club.service 65 # StateDirectory=molly-brown no unit Debian — systemd cria /var/lib/molly-brown 66 # com o dono correcto (DynamicUser) antes do ExecStart; evita conflitos com 67 # LogsDirectory + directório pré-existente em /var/log. 68 MOLLY_LOG_DIR: Final[Path] = Path("/var/lib/molly-brown") 69 MOLLY_LOGS_DROPIN_PATH: Final[Path] = Path( 70 "/etc/systemd/system/molly-brown@.service.d/50-runv-logs.conf" 71 ) 72 73 PACKAGES_GOPHER: Final[tuple[str, ...]] = ("gophernicus",) 74 PACKAGES_GEMINI: Final[tuple[str, ...]] = ("molly-brown",) 75 76 DEFAULT_USER_GOPHERMAP: Final[str] = """iBem-vindo ao teu espaço Gopher no runv.club. fake NULL 0 77 iEdita este ficheiro em ~/public_gopher/gophermap. fake NULL 0 78 """ 79 80 DEFAULT_USER_INDEX_GMI: Final[str] = """# ~{username} — runv.club 81 82 Bem-vindo ao runv.club no **Gemini**. Este é o teu espaço — escreve em `.gmi`, cria subpáginas e liga-as como quiseres. 83 84 `gemini://runv.club/~{username}/` 85 """ 86 87 88 # utilitários 89 def _path_resolved(p: Path) -> Path: 90 """Resolve o caminho; com symlinks (ex. Let's Encrypt) alinha com o canónico.""" 91 try: 92 return p.resolve(strict=False) 93 except TypeError: 94 return p.resolve() 95 96 97 def setup_logging(verbose: bool) -> logging.Logger: 98 logging.basicConfig( 99 level=logging.DEBUG if verbose else logging.INFO, 100 format="%(levelname)s: %(message)s", 101 ) 102 return logging.getLogger("setup_alt_protocols") 103 104 105 def run_cmd( 106 cmd: list[str], 107 *, 108 dry_run: bool, 109 log: logging.Logger, 110 timeout: int = 600, 111 ) -> subprocess.CompletedProcess[str] | None: 112 log.debug("exec: %s", " ".join(cmd)) 113 if dry_run: 114 log.info("[dry-run] %s", " ".join(cmd)) 115 return None 116 return subprocess.run( 117 cmd, 118 check=False, 119 capture_output=True, 120 text=True, 121 timeout=timeout, 122 ) 123 124 125 def backup_if_exists(path: Path, log: logging.Logger, dry_run: bool) -> None: 126 if not path.is_file(): 127 return 128 ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") 129 bak = path.with_suffix(path.suffix + f".bak.{ts}") 130 if dry_run: 131 log.info("[dry-run] faria backup %s -> %s", path, bak) 132 return 133 shutil.copy2(path, bak) 134 log.info("backup: %s -> %s", path, bak) 135 136 137 def infer_gopher_env_key(service_path: Path) -> str: 138 if not service_path.is_file(): 139 return "OPTIONS" 140 text = service_path.read_text(encoding="utf-8", errors="replace") 141 m = re.search(r"ExecStart=.*?\$(\w+)", text, re.MULTILINE | re.DOTALL) 142 if m: 143 return m.group(1) 144 return "OPTIONS" 145 146 147 def default_gopher_options(hostname: str) -> str: 148 return f'-h {hostname} -r {GOPHER_ROOT} -u public_gopher -o UTF-8' 149 150 151 def infer_gophernicus_runtime_user(log: logging.Logger) -> str: 152 """Lê User= do unit gophernicus@.service; fallback ``gophernicus``.""" 153 path = GOPHER_SYSTEMD_SERVICE 154 if not path.is_file(): 155 log.debug("unit gophernicus inexistente (%s) — fallback User=gophernicus", path) 156 return "gophernicus" 157 try: 158 text = path.read_text(encoding="utf-8", errors="replace") 159 except OSError as e: 160 log.debug("ler %s: %s — fallback User=gophernicus", path, e) 161 return "gophernicus" 162 m = re.search(r"^User=(\S+)", text, re.MULTILINE) 163 if not m: 164 log.debug("User= não encontrado em %s — fallback gophernicus", path) 165 return "gophernicus" 166 u = m.group(1) 167 if u.startswith("%") or "${" in u: 168 log.debug("User= dinâmico (%s) em %s — fallback gophernicus", u, path) 169 return "gophernicus" 170 return u 171 172 173 def write_gophernicus_default( 174 path: Path, 175 options_value: str, 176 *, 177 env_key: str, 178 dry_run: bool, 179 log: logging.Logger, 180 force: bool, 181 ) -> None: 182 lines: list[str] = [] 183 if path.is_file() and not force: 184 raw = path.read_text(encoding="utf-8", errors="replace").splitlines() 185 replaced = False 186 opt_re = re.compile(rf"^{re.escape(env_key)}=") 187 for line in raw: 188 if opt_re.match(line.strip()): 189 lines.append(f'{env_key}="{options_value}"') 190 replaced = True 191 else: 192 lines.append(line) 193 if not replaced: 194 lines.append(f'{env_key}="{options_value}"') 195 content = "\n".join(lines).rstrip() + "\n" 196 else: 197 content = ( 198 f"# runv.club — gerido por setup_alt_protocols.py\n" 199 f"# Ver: man gophernicus (8)\n\n" 200 f'{env_key}="{options_value}"\n' 201 ) 202 if dry_run: 203 log.info("[dry-run] gravaria %s (%s=...)", path, env_key) 204 return 205 path.parent.mkdir(parents=True, exist_ok=True) 206 path.write_text(content, encoding="utf-8") 207 os.chmod(path, 0o644) 208 log.info("atualizado: %s", path) 209 210 211 def molly_log_paths(instance: str) -> tuple[Path, Path]: 212 """Caminhos de access / error log para a instância (ex. runv.club).""" 213 return ( 214 MOLLY_LOG_DIR / f"{instance}-access.log", 215 MOLLY_LOG_DIR / f"{instance}-error.log", 216 ) 217 218 219 def retire_molly_brown_logs_dropin( 220 *, 221 dry_run: bool, 222 log: logging.Logger, 223 force: bool, 224 ) -> None: 225 """ 226 Remove 50-runv-logs.conf (LogsDirectory=molly-brown) se existir. 227 228 Esse drop-in + directório /var/log/molly-brown criado antes do arranque faz o 229 systemd migrar para /var/log/private/ e pode deixar o Molly a falhar. Os 230 logs passam a usar só StateDirectory em /var/lib/molly-brown. 231 """ 232 if not MOLLY_LOGS_DROPIN_PATH.is_file(): 233 return 234 if dry_run: 235 log.info("[dry-run] removeria drop-in obsoleto: %s", MOLLY_LOGS_DROPIN_PATH) 236 return 237 if force: 238 backup_if_exists(MOLLY_LOGS_DROPIN_PATH, log, dry_run=False) 239 MOLLY_LOGS_DROPIN_PATH.unlink() 240 log.info("removido drop-in obsoleto (logs em StateDirectory): %s", MOLLY_LOGS_DROPIN_PATH) 241 242 243 def ensure_molly_log_files( 244 instance: str, 245 *, 246 dry_run: bool, 247 log: logging.Logger, 248 ) -> tuple[Path, Path]: 249 """ 250 Devolve caminhos AccessLog/ErrorLog sob StateDirectory (/var/lib/molly-brown). 251 252 Não cria directório nem ficheiros: o unit Debian já define StateDirectory e o 253 systemd prepara /var/lib/molly-brown antes do ExecStart. Molly-brown não 254 aceita AccessLog/ErrorLog = \"-\" (interpreta como path /- e falha). 255 """ 256 access_p, error_p = molly_log_paths(instance) 257 if dry_run: 258 log.info( 259 "[dry-run] AccessLog/ErrorLog seriam %s, %s (StateDirectory systemd)", 260 access_p, 261 error_p, 262 ) 263 return access_p, error_p 264 265 log.info( 266 "logs Molly (StateDirectory): %s, %s", 267 access_p, 268 error_p, 269 ) 270 return access_p, error_p 271 272 273 def molly_brown_conf_text( 274 *, 275 hostname: str, 276 cert: Path, 277 key: Path, 278 access_log: Path, 279 error_log: Path, 280 ) -> str: 281 return f"""# runv.club — gerido por setup_alt_protocols.py 282 Hostname = "{hostname}" 283 Port = 1965 284 DocBase = "{GEMINI_ROOT.as_posix()}" 285 HomeDocBase = "users" 286 CertPath = "{cert.as_posix()}" 287 KeyPath = "{key.as_posix()}" 288 AccessLog = "{access_log.as_posix()}" 289 ErrorLog = "{error_log.as_posix()}" 290 GeminiExt = "gmi" 291 ReadMollyFiles = true 292 293 # Molly Brown (Go): resolvePath usa o *primeiro* segmento após / como ~NOME — ou seja 294 # path canónico /~username/… (tilde colado ao utilizador). O formato /~/username/ 295 # deixa o nome vazio e devolve 51; redireccionamos /~/… -> /~… antes do Stat. 296 # 297 # Conteúdo por utilizador: bind mount (não symlink) de DocBase/users/<user> para 298 # ~/public_gemini — o pacote Debian recusa symlinks fora do DocBase. 299 [TempRedirects] 300 "^/~/([^/]+)(/.*)?$" = "/~$1$2" 301 """ 302 303 304 def repo_root() -> Path: 305 """Raiz do repositório runv-server (scripts/admin/ → …/runv-server).""" 306 return Path(__file__).resolve().parent.parent.parent 307 308 309 def load_patch_irc_module(log: logging.Logger) -> Any: 310 path = repo_root() / "patches" / "patch_irc.py" 311 if not path.is_file(): 312 log.error( 313 "Ficheiro em falta: %s — clone completo do repo ou copie patches/patch_irc.py.", 314 path, 315 ) 316 raise FileNotFoundError(str(path)) 317 spec = importlib.util.spec_from_file_location("patch_irc_setup_alt", path) 318 if spec is None or spec.loader is None: 319 raise ImportError(f"não foi possível carregar {path}") 320 mod = importlib.util.module_from_spec(spec) 321 spec.loader.exec_module(mod) 322 return mod 323 324 325 _IRC_PATCH_SKIP_USERS_CACHE: frozenset[str] | None = None 326 327 328 def irc_patch_skip_users(log: logging.Logger) -> frozenset[str]: 329 """Contas em ``IRC_PATCH_SKIP_USERS`` (sem IRC / sem bind Gemini / fora dos índices raiz).""" 330 global _IRC_PATCH_SKIP_USERS_CACHE 331 if _IRC_PATCH_SKIP_USERS_CACHE is None: 332 _IRC_PATCH_SKIP_USERS_CACHE = load_patch_irc_module(log).IRC_PATCH_SKIP_USERS 333 return _IRC_PATCH_SKIP_USERS_CACHE 334 335 336 def resolve_backfill_users( 337 users_json: Path, 338 homes_root: Path, 339 log: logging.Logger, 340 ) -> list[str]: 341 """União users.json + /home, mesma política que patches/patch_irc.py.""" 342 patch_irc = load_patch_irc_module(log) 343 return patch_irc.resolve_all_users(users_json, homes_root, log) 344 345 346 def wait_for_unit_active( 347 unit: str, 348 *, 349 log: logging.Logger, 350 dry_run: bool, 351 attempts: int = 5, 352 delay_s: float = 1.0, 353 ) -> bool: 354 if dry_run: 355 return True 356 for i in range(attempts): 357 r = subprocess.run( 358 ["systemctl", "is-active", unit], 359 capture_output=True, 360 text=True, 361 timeout=30, 362 ) 363 state = (r.stdout or "").strip() 364 if state == "active": 365 log.info("%s: active", unit) 366 return True 367 log.debug("%s: %s (tentativa %d/%d)", unit, state or r.returncode, i + 1, attempts) 368 if i + 1 < attempts: 369 time.sleep(delay_s) 370 log.warning( 371 "%s não ficou «active» após %d tentativas — veja: sudo journalctl -u %s -b --no-pager", 372 unit, 373 attempts, 374 unit, 375 ) 376 log_systemd_unit_failed_hint(unit, log) 377 return False 378 379 380 def ensure_le_tls_readable_for_molly( 381 cert_path: Path, 382 key_path: Path, 383 *, 384 dry_run: bool, 385 log: logging.Logger, 386 ) -> None: 387 """ 388 Ajusta /etc/letsencrypt/live, archive, live/<domínio>, archive/<domínio> para 755 e 389 archive/<domínio>/privkey*.pem para grupo ssl-cert + 640, para o molly-brown ler a chave. 390 391 Usa caminhos lógicos (sem resolver fullchain.pem → archive), porque o symlink típico do 392 Let's Encrypt fazia falhar a detecção quando se aplicava resolve() ao certificado. 393 """ 394 cert_p = Path(cert_path) 395 key_p = Path(key_path) 396 397 try: 398 cert_rel = cert_p.relative_to(LETSENCRYPT_LIVE) 399 except ValueError: 400 log.debug( 401 "LE TLS: cert_path não está sob %s (%s) — salto", 402 LETSENCRYPT_LIVE, 403 cert_p, 404 ) 405 return 406 407 cparts = cert_rel.parts 408 if len(cparts) < 2: 409 log.debug( 410 "LE TLS: esperado %s/<domínio>/<ficheiro> — salto (%s)", 411 LETSENCRYPT_LIVE, 412 cert_p, 413 ) 414 return 415 domain = cparts[0] 416 417 try: 418 key_rel = key_p.relative_to(LETSENCRYPT_LIVE) 419 except ValueError: 420 log.debug( 421 "LE TLS: key_path não está sob %s (%s) — salto", 422 LETSENCRYPT_LIVE, 423 key_p, 424 ) 425 return 426 if not key_rel.parts or key_rel.parts[0] != domain: 427 log.debug( 428 "LE TLS: key_path não está sob %s/%s/ — salto (%s)", 429 LETSENCRYPT_LIVE, 430 domain, 431 key_p, 432 ) 433 return 434 435 live_domain_dir = LETSENCRYPT_LIVE / domain 436 archive_domain_dir = LETSENCRYPT_ARCHIVE / domain 437 438 try: 439 ssl_gid = grp.getgrnam(SSL_CERT_GROUP).gr_gid 440 except KeyError: 441 log.warning( 442 "LE TLS: grupo %r inexistente — não ajusto privkey*.pem (instale openssl/ssl-cert)", 443 SSL_CERT_GROUP, 444 ) 445 ssl_gid = None 446 447 dirs_755: list[Path] = [ 448 LETSENCRYPT_LIVE, 449 LETSENCRYPT_ARCHIVE, 450 live_domain_dir, 451 ] 452 if archive_domain_dir.is_dir(): 453 dirs_755.append(archive_domain_dir) 454 455 for d in dirs_755: 456 if not d.is_dir(): 457 log.info("LE TLS: omito chmod 755 (não existe): %s", d) 458 continue 459 if dry_run: 460 log.info("[dry-run] chmod 755 %s", d) 461 continue 462 try: 463 before = stat.S_IMODE(os.stat(d).st_mode) 464 os.chmod(d, 0o755) 465 if before != 0o755: 466 log.info("LE TLS: %s modo %04o -> 0755", d, before) 467 except OSError as e: 468 log.warning("LE TLS: chmod %s: %s", d, e) 469 470 if not archive_domain_dir.is_dir(): 471 log.info("LE TLS: %s inexistente — sem privkey*.pem", archive_domain_dir) 472 return 473 474 privkeys = sorted(archive_domain_dir.glob("privkey*.pem")) 475 if not privkeys: 476 log.info("LE TLS: sem privkey*.pem em %s", archive_domain_dir) 477 return 478 479 if ssl_gid is None: 480 log.warning("LE TLS: sem grupo ssl-cert — não altero privkey em %s", archive_domain_dir) 481 return 482 483 for pk in privkeys: 484 if not pk.is_file(): 485 continue 486 if dry_run: 487 log.info("[dry-run] chgrp %s %s && chmod 640 %s", SSL_CERT_GROUP, pk, pk) 488 continue 489 try: 490 st = os.stat(pk) 491 os.chown(pk, st.st_uid, ssl_gid) 492 before_m = stat.S_IMODE(st.st_mode) 493 os.chmod(pk, 0o640) 494 if before_m != 0o640: 495 log.info("LE TLS: %s modo %04o -> 0640, grupo %s", pk, before_m, SSL_CERT_GROUP) 496 except OSError as e: 497 log.warning("LE TLS: ajuste %s: %s", pk, e) 498 499 500 def ensure_user_public_dirs( 501 username: str, 502 homes_root: Path, 503 *, 504 force: bool, 505 dry_run: bool, 506 log: logging.Logger, 507 ) -> None: 508 try: 509 pw = pwd.getpwnam(username) 510 except KeyError: 511 log.warning("utilizador %s não existe no sistema — salto backfill", username) 512 return 513 home = Path(pw.pw_dir) 514 uid, gid = pw.pw_uid, pw.pw_gid 515 gdir = home / "public_gopher" 516 gmap = gdir / "gophermap" 517 xdir = home / "public_gemini" 518 xidx = xdir / "index.gmi" 519 520 if dry_run: 521 log.info("[dry-run] garantiria ~/public_gopher e ~/public_gemini para %s", username) 522 if home.is_dir(): 523 try: 524 cur = stat.S_IMODE(os.stat(home).st_mode) 525 except OSError as e: 526 log.debug("[dry-run] stat home %s: %s", home, e) 527 else: 528 if cur != 0o755: 529 log.info("[dry-run] chmod 755 %s (era %04o)", home, cur) 530 return 531 532 gdir.mkdir(parents=True, exist_ok=True) 533 xdir.mkdir(parents=True, exist_ok=True) 534 os.chmod(gdir, 0o755) 535 os.chmod(xdir, 0o755) 536 os.chown(gdir, uid, gid) 537 os.chown(xdir, uid, gid) 538 539 if not gmap.exists() or force: 540 if gmap.exists() and force: 541 backup_if_exists(gmap, log, dry_run=False) 542 gmap.write_text(DEFAULT_USER_GOPHERMAP, encoding="utf-8") 543 os.chmod(gmap, 0o644) 544 os.chown(gmap, uid, gid) 545 log.info("gophermap: %s", gmap) 546 else: 547 log.debug("gophermap já existe, mantido: %s", gmap) 548 549 # index.gmi: nunca sobrescrever se já existir (--force não aplica ao modelo Gemini). 550 if not xidx.exists(): 551 xidx.write_text( 552 DEFAULT_USER_INDEX_GMI.format(username=username), 553 encoding="utf-8", 554 ) 555 os.chmod(xidx, 0o644) 556 os.chown(xidx, uid, gid) 557 log.info("index.gmi: %s", xidx) 558 else: 559 log.debug("index.gmi já existe, mantido: %s", xidx) 560 561 if home.is_dir(): 562 try: 563 cur = stat.S_IMODE(os.stat(home).st_mode) 564 except OSError as e: 565 log.warning("stat home %s: %s", home, e) 566 else: 567 if cur != 0o755: 568 os.chmod(home, 0o755) 569 log.info("home %s: modo %04o -> 0755 (atravessável por serviços)", home, cur) 570 571 572 def _escape_fstab_path(s: str) -> str: 573 return s.replace(" ", "\\040") 574 575 576 def _unescape_fstab_path(s: str) -> str: 577 return s.replace("\\040", " ") 578 579 580 def _is_dir_mountpoint(path: Path) -> bool: 581 r = subprocess.run( 582 ["mountpoint", "-q", str(path)], 583 capture_output=True, 584 timeout=30, 585 ) 586 return r.returncode == 0 587 588 589 def _bind_mount_source_resolved(mountpoint: Path) -> Path | None: 590 r = subprocess.run( 591 ["findmnt", "-n", "-o", "SOURCE", "--target", str(mountpoint)], 592 capture_output=True, 593 text=True, 594 timeout=30, 595 ) 596 if r.returncode != 0: 597 return None 598 raw = (r.stdout or "").strip() 599 if not raw: 600 return None 601 src = raw.split()[0] 602 if src.startswith("[") and "]" in src: 603 src = src[1 : src.index("]")] 604 try: 605 return Path(src).resolve(strict=False) 606 except OSError: 607 return Path(src) 608 609 610 def _ensure_gemini_fstab_line( 611 source: Path, 612 mountpoint: Path, 613 *, 614 dry_run: bool, 615 log: logging.Logger, 616 ) -> None: 617 src_s = str(_path_resolved(source)) 618 mp_s = str(_path_resolved(mountpoint)) 619 desired_line = f"{_escape_fstab_path(src_s)} {_escape_fstab_path(mp_s)} none bind 0 0\n" 620 if dry_run: 621 log.info("[dry-run] fstab (se necessário): %s", desired_line.rstrip()) 622 return 623 if not FSTAB_PATH.is_file(): 624 log.warning("%s inexistente — bind não persistido após reboot", FSTAB_PATH) 625 return 626 try: 627 text = FSTAB_PATH.read_text(encoding="utf-8", errors="replace") 628 except OSError as e: 629 log.warning("ler fstab: %s", e) 630 return 631 mp_path = mountpoint 632 src_res = Path(src_s).resolve() 633 kept: list[str] = [] 634 found_exact = False 635 for line in text.splitlines(keepends=True): 636 stripped = line.strip() 637 if stripped.startswith("#") or not stripped: 638 kept.append(line) 639 continue 640 m = _GEMINI_BIND_FSTAB_RE.match(stripped) 641 if not m: 642 kept.append(line) 643 continue 644 f2 = Path(_unescape_fstab_path(m.group(2))) 645 if f2 != mp_path: 646 kept.append(line) 647 continue 648 f1 = Path(_unescape_fstab_path(m.group(1))).resolve() 649 if f1 == src_res: 650 if not found_exact: 651 found_exact = True 652 kept.append(line) 653 else: 654 log.debug("fstab: duplicado ignorado para %s", mountpoint) 655 else: 656 log.debug("fstab: removida linha antiga para %s (origem diferente)", mountpoint) 657 if not found_exact: 658 if kept and not kept[-1].endswith("\n"): 659 kept[-1] += "\n" 660 kept.append(desired_line) 661 new_content = "".join(kept) 662 if new_content == text: 663 log.debug("fstab inalterado para %s", mountpoint) 664 return 665 backup_if_exists(FSTAB_PATH, log, dry_run=False) 666 FSTAB_PATH.write_text(new_content, encoding="utf-8") 667 log.info("fstab: bind persistido %s -> %s", src_s, mp_s) 668 669 670 def _remove_gemini_fstab_lines_for_mountpoint(mountpoint: Path, log: logging.Logger) -> None: 671 """Remove todas as linhas ``bind`` do fstab cujo segundo campo é ``mountpoint``.""" 672 if not FSTAB_PATH.is_file(): 673 return 674 try: 675 text = FSTAB_PATH.read_text(encoding="utf-8", errors="replace") 676 except OSError as e: 677 log.warning("ler fstab: %s", e) 678 return 679 new_lines: list[str] = [] 680 removed = False 681 for line in text.splitlines(keepends=True): 682 stripped = line.strip() 683 if stripped.startswith("#") or not stripped: 684 new_lines.append(line) 685 continue 686 m = _GEMINI_BIND_FSTAB_RE.match(stripped) 687 if m and Path(_unescape_fstab_path(m.group(2))) == mountpoint: 688 removed = True 689 continue 690 new_lines.append(line) 691 if not removed: 692 return 693 new_content = "".join(new_lines) 694 backup_if_exists(FSTAB_PATH, log, dry_run=False) 695 FSTAB_PATH.write_text(new_content, encoding="utf-8") 696 log.info("fstab: removida(s) linha(s) bind para %s", mountpoint) 697 698 699 def remove_gemini_bind_mount( 700 username: str, 701 *, 702 dry_run: bool, 703 log: logging.Logger, 704 ) -> None: 705 """Desmonta ``/var/gemini/users/<user>``, limpa fstab, symlink ou directório vazio.""" 706 mountpoint = GEMINI_USERS / username 707 if dry_run: 708 log.info("[dry-run] removeria bind Gemini / fstab em %s", mountpoint) 709 return 710 if _is_dir_mountpoint(mountpoint): 711 ru = run_cmd(["umount", str(mountpoint)], dry_run=False, log=log) 712 if ru is not None and ru.returncode != 0: 713 log.warning( 714 "umount %s: %s", 715 mountpoint, 716 (ru.stderr or ru.stdout or "").strip(), 717 ) 718 _remove_gemini_fstab_lines_for_mountpoint(mountpoint, log) 719 if mountpoint.is_symlink(): 720 try: 721 mountpoint.unlink() 722 log.info("symlink Gemini removido: %s", mountpoint) 723 except OSError as e: 724 log.warning("unlink %s: %s", mountpoint, e) 725 if mountpoint.is_dir(): 726 try: 727 if not any(mountpoint.iterdir()): 728 mountpoint.rmdir() 729 log.info("directório Gemini vazio removido: %s", mountpoint) 730 except OSError as e: 731 log.warning("%s: %s", mountpoint, e) 732 733 734 def build_root_gophermap_text( 735 hostname: str, 736 homes_root: Path, 737 users: list[str], 738 ) -> str: 739 """Menu raiz com links ``1~user`` só para contas com ``~/public_gopher`` (exclui IRC_PATCH_SKIP).""" 740 tab = "\t" 741 lines: list[str] = [ 742 "!runv.club — Gopher", 743 f"iBem-vindo ao Gopher em {hostname} — pubnix.{tab}fake{tab}NULL{tab}0", 744 f"iMembros com espaço público (selector ~utilizador/).{tab}fake{tab}NULL{tab}0", 745 "#", 746 ] 747 for u in sorted(users): 748 if not (homes_root / u / "public_gopher").is_dir(): 749 continue 750 lines.append(f"1~{u}{tab}~{u}/{tab}{hostname}{tab}70") 751 return "\n".join(lines) + "\n" 752 753 754 def build_root_gemini_index_gmi( 755 hostname: str, 756 homes_root: Path, 757 users: list[str], 758 ) -> str: 759 """Índice Gemtext na raiz do DocBase; mesmos membros que no menu Gopher raiz.""" 760 lines: list[str] = [ 761 f"# {hostname} — Gemini", 762 "", 763 f"Bem-vindo ao **Gemini** do **{hostname}**.", 764 "", 765 "## Capsules dos membros", 766 "", 767 ] 768 for u in sorted(users): 769 if not (homes_root / u / "public_gopher").is_dir(): 770 continue 771 lines.append(f"=> gemini://{hostname}/~{u}/ Capsule ~{u}") 772 lines.append("") 773 return "\n".join(lines) 774 775 776 def ensure_gemini_bind_mount( 777 username: str, 778 homes_root: Path, 779 *, 780 force: bool, 781 dry_run: bool, 782 log: logging.Logger, 783 ) -> None: 784 """ 785 Expõe ~/public_gemini em /var/gemini/users/<user> com mount --bind + fstab. 786 O Molly Debian recusa symlinks cujo destino fica fora de DocBase (/var/gemini). 787 Contas em IRC_PATCH_SKIP_USERS não recebem bind; com force remove-se mount/fstab. 788 """ 789 _ = homes_root # API compatível com o backfill (getpwnam fornece a home) 790 try: 791 pw = pwd.getpwnam(username) 792 except KeyError: 793 return 794 795 sk = irc_patch_skip_users(log) 796 if username in sk: 797 if dry_run: 798 log.info("[dry-run] %s em IRC_PATCH_SKIP_USERS — bind Gemini omitido", username) 799 return 800 if force: 801 remove_gemini_bind_mount(username, dry_run=False, log=log) 802 else: 803 mp = GEMINI_USERS / username 804 if _is_dir_mountpoint(mp) or mp.is_symlink(): 805 log.warning( 806 "%s está em IRC_PATCH_SKIP_USERS mas há mount ou symlink em %s — " 807 "use --force para remover", 808 username, 809 mp, 810 ) 811 else: 812 log.debug("skip bind Gemini (IRC_PATCH_SKIP_USERS): %s", username) 813 return 814 815 home = Path(pw.pw_dir) 816 target = home / "public_gemini" 817 if not target.is_dir(): 818 log.debug("public_gemini inexistente para %s — bind não aplicado", username) 819 return 820 target_resolved = _path_resolved(target) 821 mountpoint = GEMINI_USERS / username 822 823 if not GEMINI_USERS.is_dir(): 824 log.warning("GEMINI_USERS inexistente: %s — bind não aplicado", GEMINI_USERS) 825 return 826 827 if dry_run: 828 log.info("[dry-run] mount --bind %s %s + fstab", target_resolved, mountpoint) 829 _ensure_gemini_fstab_line(target_resolved, mountpoint, dry_run=True, log=log) 830 return 831 832 if mountpoint.is_symlink(): 833 if not force: 834 log.warning( 835 "symlink %s -> %s: Molly Debian recusa symlinks fora do DocBase; " 836 "corra com --force para substituir por bind mount", 837 mountpoint, 838 mountpoint.resolve(), 839 ) 840 return 841 mountpoint.unlink() 842 log.info("symlink removido (migração bind): %s", mountpoint) 843 844 if mountpoint.exists() and mountpoint.is_file(): 845 log.warning("%s é ficheiro; não aplico bind", mountpoint) 846 return 847 848 if _is_dir_mountpoint(mountpoint): 849 src_now = _bind_mount_source_resolved(mountpoint) 850 if src_now == target_resolved: 851 log.debug("bind mount OK: %s <- %s", mountpoint, target_resolved) 852 _ensure_gemini_fstab_line(target_resolved, mountpoint, dry_run=False, log=log) 853 return 854 if not force: 855 log.warning( 856 "mountpoint %s montado de %s; esperado %s — use --force", 857 mountpoint, 858 src_now, 859 target_resolved, 860 ) 861 return 862 ru = run_cmd(["umount", str(mountpoint)], dry_run=False, log=log) 863 if ru is not None and ru.returncode != 0: 864 log.error( 865 "umount %s falhou: %s", 866 mountpoint, 867 (ru.stderr or ru.stdout or "").strip(), 868 ) 869 return 870 log.info("umount antes de remount: %s", mountpoint) 871 872 if mountpoint.exists() and mountpoint.is_dir(): 873 try: 874 if any(mountpoint.iterdir()): 875 log.warning( 876 "%s é directório com conteúdo (não é mountpoint); não aplico bind", 877 mountpoint, 878 ) 879 return 880 except OSError as e: 881 log.warning("listar %s: %s", mountpoint, e) 882 return 883 884 mountpoint.mkdir(parents=True, exist_ok=True) 885 os.chmod(mountpoint, 0o755) 886 try: 887 os.chown(mountpoint, 0, 0) 888 except OSError as e: 889 log.warning("chown %s: %s", mountpoint, e) 890 891 rm = run_cmd( 892 ["mount", "--bind", str(target_resolved), str(mountpoint)], 893 dry_run=False, 894 log=log, 895 ) 896 if rm is None or rm.returncode != 0: 897 log.error( 898 "mount --bind falhou: %s -> %s (%s)", 899 target_resolved, 900 mountpoint, 901 (rm.stderr or rm.stdout or "").strip() if rm else "", 902 ) 903 return 904 log.info("bind mount: %s -> %s", target_resolved, mountpoint) 905 _ensure_gemini_fstab_line(target_resolved, mountpoint, dry_run=False, log=log) 906 907 908 # Alias legado (patches/yetgg.py e referências antigas) 909 ensure_gemini_symlink = ensure_gemini_bind_mount 910 911 912 def apt_install( 913 packages: tuple[str, ...], 914 *, 915 dry_run: bool, 916 log: logging.Logger, 917 ) -> bool: 918 env = {"DEBIAN_FRONTEND": "noninteractive", "LC_ALL": "C"} 919 r1 = run_cmd(["apt-get", "update", "-qq"], dry_run=dry_run, log=log) 920 if not dry_run and r1 is not None and r1.returncode != 0: 921 log.error("apt-get update falhou: %s", (r1.stderr or r1.stdout or "").strip()) 922 return False 923 cmd = ["apt-get", "install", "-y", "--no-install-recommends", *packages] 924 r2 = run_cmd(cmd, dry_run=dry_run, log=log) 925 if dry_run: 926 return True 927 if r2 is None or r2.returncode != 0: 928 log.error("apt-get install falhou: %s", (r2.stderr or r2.stdout or "").strip() if r2 else "") 929 return False 930 return True 931 932 933 def log_ufw_suggested_commands(log: logging.Logger) -> None: 934 """Comandos para copiar quando o script não aplicou regras UFW automaticamente.""" 935 log.info( 936 "Se usar UFW, depois de «sudo ufw enable» (se ainda não estiver activo), execute:\n" 937 " sudo ufw allow 70/tcp comment 'gopher'\n" 938 " sudo ufw allow 1965/tcp comment 'gemini'\n" 939 " sudo ufw reload" 940 ) 941 942 943 def log_systemd_unit_failed_hint(unit: str, log: logging.Logger) -> None: 944 """Se o unit estiver em estado failed, regista ERROR com ponteiro para journalctl.""" 945 r = subprocess.run( 946 ["systemctl", "is-failed", unit], 947 capture_output=True, 948 text=True, 949 timeout=30, 950 ) 951 if r.returncode != 0: 952 return 953 log.error( 954 "%s está em estado «failed» — diagnóstico: sudo journalctl -u %s -b --no-pager -n 80", 955 unit, 956 unit, 957 ) 958 959 960 def dpkg_installed(package: str) -> bool: 961 r = subprocess.run( 962 ["dpkg", "-s", package], 963 capture_output=True, 964 text=True, 965 timeout=30, 966 ) 967 return r.returncode == 0 and "Status: install ok installed" in (r.stdout or "") 968 969 970 def ufw_maybe_allow( 971 ports: list[tuple[int, str]], 972 *, 973 dry_run: bool, 974 log: logging.Logger, 975 skip_firewall: bool, 976 ) -> None: 977 if skip_firewall: 978 log.info("firewall ignorado (--skip-firewall)") 979 log_ufw_suggested_commands(log) 980 return 981 r = subprocess.run( 982 ["ufw", "status"], 983 capture_output=True, 984 text=True, 985 timeout=30, 986 ) 987 out = (r.stdout or "").lower() 988 if r.returncode != 0 or "status: active" not in out: 989 log.warning( 990 "UFW não está ativo (ou comando falhou). Não abro portas automaticamente. " 991 "Abra 70/tcp (Gopher) e 1965/tcp (Gemini) se usar firewall." 992 ) 993 log_ufw_suggested_commands(log) 994 return 995 for port, label in ports: 996 cmd = ["ufw", "allow", f"{port}/tcp"] 997 run_cmd(cmd, dry_run=dry_run, log=log) 998 log.info("UFW: permitido %s/tcp (%s)", port, label) 999 1000 1001 def _runuser_can_read( 1002 path: Path, 1003 run_as: str, 1004 *, 1005 dry_run: bool, 1006 log: logging.Logger, 1007 ) -> bool | None: 1008 """None = skip (sem runuser), True/False = resultado de ``test -r`` como *run_as*.""" 1009 if dry_run: 1010 if shutil.which("runuser"): 1011 log.info("[dry-run] runuser -u %s -- test -r %s", run_as, path) 1012 else: 1013 log.info("[dry-run] (runuser ausente) test -r como %s em %s", run_as, path) 1014 return None 1015 if not shutil.which("runuser"): 1016 log.debug("validação runuser: binário não encontrado — salto test -r") 1017 return None 1018 r = subprocess.run( 1019 ["runuser", "-u", run_as, "--", "test", "-r", str(path)], 1020 capture_output=True, 1021 text=True, 1022 timeout=30, 1023 ) 1024 return r.returncode == 0 1025 1026 1027 def _www_data_can_read(path: Path, *, dry_run: bool, log: logging.Logger) -> bool | None: 1028 """Heurística de leitura como ``www-data`` (ACL POSIX pode afectar o UID real do Molly).""" 1029 return _runuser_can_read(path, "www-data", dry_run=dry_run, log=log) 1030 1031 1032 def validate_final( 1033 usernames: list[str], 1034 log: logging.Logger, 1035 *, 1036 dry_run: bool = False, 1037 ) -> None: 1038 log.info("--- validação final ---") 1039 for pkg, label in (("gophernicus", "Gopher"), ("molly-brown", "Gemini")): 1040 ok = dpkg_installed(pkg) 1041 log.info("pacote %s (%s): %s", pkg, label, "OK" if ok else "AUSENTE") 1042 1043 r = subprocess.run( 1044 ["systemctl", "is-active", "gophernicus.socket"], 1045 capture_output=True, 1046 text=True, 1047 timeout=30, 1048 ) 1049 gopher_state = (r.stdout or "").strip() 1050 log.info("gophernicus.socket: %s", gopher_state or r.returncode) 1051 1052 molly_unit = f"molly-brown@{MOLLY_INSTANCE}.service" 1053 r2 = subprocess.run( 1054 ["systemctl", "is-active", molly_unit], 1055 capture_output=True, 1056 text=True, 1057 timeout=30, 1058 ) 1059 molly_state = (r2.stdout or "").strip() or str(r2.returncode) 1060 log.info("molly-brown@%s: %s", MOLLY_INSTANCE, molly_state) 1061 if molly_state != "active": 1062 log.warning( 1063 "molly-brown não está «active» (estado reportado: %s). " 1064 "«activating» durante o script não significa sucesso — confirme com " 1065 "«systemctl is-active %s» e «sudo ss -tlnp | grep 1965».", 1066 molly_state, 1067 molly_unit, 1068 ) 1069 log_systemd_unit_failed_hint(molly_unit, log) 1070 1071 if usernames: 1072 sk = irc_patch_skip_users(log) 1073 visible = [u for u in usernames if u not in sk] 1074 sample = visible[0] if visible else usernames[0] 1075 try: 1076 pw = pwd.getpwnam(sample) 1077 home = Path(pw.pw_dir) 1078 for p, label in ( 1079 (home / "public_gopher" / "gophermap", "gophermap"), 1080 (home / "public_gemini" / "index.gmi", "index.gmi"), 1081 ): 1082 log.info("amostra %s %s: %s", sample, label, "OK" if p.is_file() else "FALTA") 1083 mp = GEMINI_USERS / sample 1084 pg = (home / "public_gemini").resolve() 1085 ok_mount = False 1086 if _is_dir_mountpoint(mp): 1087 src = _bind_mount_source_resolved(mp) 1088 ok_mount = src is not None and src == pg 1089 elif mp.is_symlink(): 1090 log.warning( 1091 "amostra %s: %s ainda é symlink (Molly Debian rejeita); " 1092 "corra setup_alt_protocols com --force para bind mount", 1093 sample, 1094 mp, 1095 ) 1096 log.info("amostra mount Gemini: %s", "OK" if ok_mount else "FALTA/INCORRETO") 1097 gophermap = home / "public_gopher" / "gophermap" 1098 if gopher_state == "active" and gophermap.is_file(): 1099 guser = infer_gophernicus_runtime_user(log) 1100 gcan = _runuser_can_read(gophermap, guser, dry_run=dry_run, log=log) 1101 if gcan is False: 1102 log.warning( 1103 "amostra %s: utilizador %s (gophernicus) não consegue ler %s " 1104 "(runuser … test -r falhou). Confirme home 755 (ou o+x), " 1105 "public_gopher 755, gophermap 644.", 1106 sample, 1107 guser, 1108 gophermap, 1109 ) 1110 elif gcan is True: 1111 log.info( 1112 "amostra %s: gophermap legível pelo utilizador do serviço (%s, test -r): OK", 1113 sample, 1114 guser, 1115 ) 1116 index_gmi = home / "public_gemini" / "index.gmi" 1117 if molly_state == "active" and index_gmi.is_file(): 1118 can = _www_data_can_read(index_gmi, dry_run=dry_run, log=log) 1119 if can is False: 1120 log.warning( 1121 "amostra %s: www-data não consegue ler %s (runuser … test -r falhou). " 1122 "Confirme home 755 (ou o+x), public_gemini 755, index.gmi 644, bind %s; " 1123 "se `ls -l` mostrar +, veja getfacl no path (ACL).", 1124 sample, 1125 index_gmi, 1126 mp, 1127 ) 1128 elif can is True: 1129 log.info("amostra %s: index.gmi legível por www-data (test -r): OK", sample) 1130 except KeyError: 1131 log.info("amostra %s: utilizador não existe neste sistema", sample) 1132 1133 1134 def parse_args(argv: list[str] | None) -> argparse.Namespace: 1135 p = argparse.ArgumentParser( 1136 description="Instala/configura Gopher (gophernicus) e Gemini (molly-brown) para runv.club.", 1137 ) 1138 p.add_argument("--dry-run", action="store_true") 1139 p.add_argument("--verbose", action="store_true") 1140 p.add_argument( 1141 "--force", 1142 action="store_true", 1143 help="sobrescreve configs e ficheiros modelo com backup (index.gmi existente nunca é substituído)", 1144 ) 1145 p.add_argument("--skip-install", action="store_true") 1146 p.add_argument("--skip-gopher", action="store_true") 1147 p.add_argument("--skip-gemini", action="store_true") 1148 p.add_argument("--skip-firewall", action="store_true") 1149 p.add_argument("--skip-backfill", action="store_true") 1150 p.add_argument("--skip-services", action="store_true") 1151 p.add_argument("--skip-system-config", action="store_true") 1152 p.add_argument("--users-json", type=Path, default=DEFAULT_USERS_JSON) 1153 p.add_argument("--homes-root", type=Path, default=DEFAULT_HOMES_ROOT) 1154 p.add_argument("--gemini-hostname", default=DEFAULT_GEMINI_HOSTNAME) 1155 p.add_argument("--gemini-cert", type=Path, default=None) 1156 p.add_argument("--gemini-key", type=Path, default=None) 1157 p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") 1158 return p.parse_args(argv) 1159 1160 1161 def main(argv: list[str] | None = None) -> int: 1162 args = parse_args(argv) 1163 ensure_admin_cli( 1164 script_name=Path(__file__).name, 1165 dry_run=bool(args.dry_run), 1166 ) 1167 log = setup_logging(args.verbose) 1168 1169 if os.geteuid() != 0 and not args.dry_run: 1170 log.error("Execute como root (sudo).") 1171 return 1 1172 1173 cert = args.gemini_cert or DEFAULT_LE_CERT 1174 key = args.gemini_key or DEFAULT_LE_KEY 1175 1176 try: 1177 backfill_users = resolve_backfill_users(args.users_json, args.homes_root, log) 1178 except (FileNotFoundError, ImportError) as e: 1179 log.error("%s", e) 1180 return 1 1181 1182 if not args.skip_gemini: 1183 ensure_le_tls_readable_for_molly(cert, key, dry_run=args.dry_run, log=log) 1184 1185 pkgs: list[str] = [] 1186 if not args.skip_install: 1187 if not args.skip_gopher: 1188 pkgs.extend(PACKAGES_GOPHER) 1189 if not args.skip_gemini: 1190 pkgs.extend(PACKAGES_GEMINI) 1191 pkgs = sorted(set(pkgs)) 1192 if pkgs: 1193 log.info("instalação apt: %s", ", ".join(pkgs)) 1194 if not apt_install(tuple(pkgs), dry_run=args.dry_run, log=log): 1195 return 1 1196 1197 if not args.skip_system_config: 1198 env_key = infer_gopher_env_key(GOPHER_SYSTEMD_SERVICE) 1199 opts = default_gopher_options(args.gemini_hostname) 1200 1201 if not args.skip_gopher: 1202 if args.force and GOPHER_DEFAULT_PATH.is_file(): 1203 backup_if_exists(GOPHER_DEFAULT_PATH, log, args.dry_run) 1204 write_gophernicus_default( 1205 GOPHER_DEFAULT_PATH, 1206 opts, 1207 env_key=env_key, 1208 dry_run=args.dry_run, 1209 log=log, 1210 force=args.force, 1211 ) 1212 if not args.dry_run: 1213 GOPHER_ROOT.mkdir(parents=True, exist_ok=True) 1214 os.chmod(GOPHER_ROOT, 0o755) 1215 root_map = GOPHER_ROOT / "gophermap" 1216 gmap_body = build_root_gophermap_text( 1217 args.gemini_hostname, 1218 args.homes_root, 1219 backfill_users, 1220 ) 1221 if not root_map.exists() or args.force: 1222 if root_map.exists() and args.force: 1223 backup_if_exists(root_map, log, dry_run=False) 1224 root_map.write_text(gmap_body, encoding="utf-8") 1225 os.chmod(root_map, 0o644) 1226 n_menu = sum(1 for ln in gmap_body.splitlines() if ln.startswith("1~")) 1227 log.info("gophermap raiz: %s (%d entradas ~user)", root_map, n_menu) 1228 1229 if not args.dry_run: 1230 GEMINI_ROOT.mkdir(parents=True, exist_ok=True) 1231 GEMINI_USERS.mkdir(parents=True, exist_ok=True) 1232 os.chmod(GEMINI_ROOT, 0o755) 1233 os.chmod(GEMINI_USERS, 0o755) 1234 try: 1235 os.chown(GEMINI_ROOT, 0, 0) 1236 os.chown(GEMINI_USERS, 0, 0) 1237 except OSError as e: 1238 log.warning("chown /var/gemini: %s", e) 1239 1240 if not args.skip_gemini: 1241 gemi_root = GEMINI_ROOT / "index.gmi" 1242 gemi_body = build_root_gemini_index_gmi( 1243 args.gemini_hostname, 1244 args.homes_root, 1245 backfill_users, 1246 ) 1247 if not gemi_root.exists() or args.force: 1248 if gemi_root.exists() and args.force: 1249 backup_if_exists(gemi_root, log, dry_run=False) 1250 gemi_root.write_text(gemi_body, encoding="utf-8") 1251 os.chmod(gemi_root, 0o644) 1252 try: 1253 os.chown(gemi_root, 0, 0) 1254 except OSError as e: 1255 log.warning("chown %s: %s", gemi_root, e) 1256 log.info("index.gmi DocBase raiz: %s", gemi_root) 1257 1258 if not args.skip_gemini: 1259 if not cert.is_file() or not key.is_file(): 1260 log.error( 1261 "Certificado ou chave TLS inexistentes (Gemini/molly-brown). " 1262 "cert=%s key=%s — defina --gemini-cert / --gemini-key ou instale Let's Encrypt. " 1263 "Pastas /var/gemini foram criadas; serviço Gemini não será ativado.", 1264 cert, 1265 key, 1266 ) 1267 else: 1268 retire_molly_brown_logs_dropin( 1269 dry_run=args.dry_run, 1270 log=log, 1271 force=args.force, 1272 ) 1273 access_p, error_p = ensure_molly_log_files( 1274 MOLLY_INSTANCE, 1275 dry_run=args.dry_run, 1276 log=log, 1277 ) 1278 conf_path = MOLLY_CONF_DIR / f"{MOLLY_INSTANCE}.conf" 1279 body = molly_brown_conf_text( 1280 hostname=args.gemini_hostname, 1281 cert=cert, 1282 key=key, 1283 access_log=access_p, 1284 error_log=error_p, 1285 ) 1286 if args.dry_run: 1287 log.info("[dry-run] gravaria %s", conf_path) 1288 else: 1289 MOLLY_CONF_DIR.mkdir(parents=True, exist_ok=True) 1290 if conf_path.is_file() and args.force: 1291 backup_if_exists(conf_path, log, dry_run=False) 1292 if not conf_path.is_file() or args.force: 1293 conf_path.write_text(body, encoding="utf-8") 1294 os.chmod(conf_path, 0o644) 1295 log.info("molly-brown: %s", conf_path) 1296 1297 ufw_maybe_allow( 1298 [(70, "gopher"), (1965, "gemini")], 1299 dry_run=args.dry_run, 1300 log=log, 1301 skip_firewall=args.skip_firewall, 1302 ) 1303 1304 if not args.skip_backfill: 1305 for u in backfill_users: 1306 ensure_user_public_dirs( 1307 u, 1308 args.homes_root, 1309 force=args.force, 1310 dry_run=args.dry_run, 1311 log=log, 1312 ) 1313 ensure_gemini_bind_mount( 1314 u, 1315 args.homes_root, 1316 force=args.force, 1317 dry_run=args.dry_run, 1318 log=log, 1319 ) 1320 1321 if not args.skip_services: 1322 if not args.dry_run: 1323 run_cmd(["systemctl", "daemon-reload"], dry_run=False, log=log) 1324 if not args.skip_gopher: 1325 run_cmd( 1326 ["systemctl", "enable", "--now", "gophernicus.socket"], 1327 dry_run=args.dry_run, 1328 log=log, 1329 ) 1330 if not args.skip_gemini and cert.is_file() and key.is_file(): 1331 molly_unit = f"molly-brown@{MOLLY_INSTANCE}.service" 1332 run_cmd( 1333 ["systemctl", "enable", "--now", molly_unit], 1334 dry_run=args.dry_run, 1335 log=log, 1336 ) 1337 wait_for_unit_active( 1338 molly_unit, 1339 log=log, 1340 dry_run=args.dry_run, 1341 attempts=15, 1342 delay_s=1.0, 1343 ) 1344 1345 validate_final(backfill_users, log, dry_run=args.dry_run) 1346 log.info("Concluído.") 1347 return 0 1348 1349 1350 if __name__ == "__main__": 1351 raise SystemExit(main())