runv-server

server tooling for runv.club
Log | Files | Refs | README

commit f2732bd78e052572dd2ad915070018980dd162cd
parent e624c32b4da5167da33aed63a5e1c2e15b698b1f
Author: Pablo Murad <pblmrd@gmail.com>
Date:   Wed, 13 May 2026 22:05:40 -0300

Mariela Boca Murcha

Diffstat:
Mscripts/admin/create_runv_user.py | 38++++++++++++++++++++++++--------------
Ascripts/admin/remove_runv_jails.py | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/manifests/apt_packages.txt | 3---
Mtools/tools.py | 90++++++++++++++++++++++++++++++++-----------------------------------------------
4 files changed, 197 insertions(+), 71 deletions(-)

diff --git a/scripts/admin/create_runv_user.py b/scripts/admin/create_runv_user.py @@ -14,11 +14,9 @@ Contrato de provisionamento (ordem garantida após validação): 5. **Skel Debian** — copiado no passo 1; o skel runv (``tools.py``) **não** inclui ``README.md`` por política. Opcionalmente ``--with-readme`` cria ``~/README.md`` (``--force-readme`` substitui se existir). 6. **Aplicar permissões** — ``apply_runv_permissions``: home, ``.ssh``, sites públicos e, se existir, - ``README.md``, antes da **jail** (grupo ``runv-jailed``, Jailkit, bind, fstab), quota e verificação final. -7. **Jail SSH** — por omissão: ``usermod -aG runv-jailed``, ``/srv/jail/<user>``, ``jk_init`` - com perfil ``extendedshell`` (se ``bin/`` ainda não existir), bind de ``/home/<user>`` em - ``/srv/jail/<user>/home/<user>``, fstab. Exclui ``entre`` e - ``pmurad-admin``. ``--no-jail`` desliga. + ``README.md``, antes de quota e verificação final. +7. **Jail SSH** — legado/opt-in: use ``--with-jail`` para ``runv-jailed`` + ``/srv/jail/<user>``. + Por omissão, membros entram sem chroot para poderem usar os comandos globais do servidor. Quota ext4, metadados JSON e logging seguem após estes passos. @@ -442,8 +440,8 @@ def process_all_pending_requests(args: argparse.Namespace) -> int: passthrough_flags.append("--with-readme") if args.force_readme: passthrough_flags.append("--force-readme") - if args.no_jail: - passthrough_flags.append("--no-jail") + if getattr(args, "with_jail", False): + passthrough_flags.append("--with-jail") if args.force_gopher: passthrough_flags.append("--force-gopher") if args.force_gemini: @@ -1419,15 +1417,18 @@ def interactive_fill(args: argparse.Namespace) -> None: ) else: args.force_readme = False - args.no_jail = prompt_yes_no( - "Omitir jail SSH (runv-jailed /srv/jail) (--no-jail)?", + args.with_jail = prompt_yes_no( + "Criar jail SSH legada (runv-jailed /srv/jail) (--with-jail)?", default_no=True, ) + args.no_jail = not args.with_jail else: args.force_index = False args.force_gopher = False args.force_gemini = False args.force_readme = False + args.with_jail = False + args.no_jail = True args.verbose = prompt_yes_no("Log verboso no terminal?", default_no=True) @@ -1778,7 +1779,12 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: p.add_argument( "--no-jail", action="store_true", - help="não adicionar a runv-jailed nem criar jail em /srv/jail", + help="compatibilidade: não adicionar a runv-jailed nem criar jail em /srv/jail (padrão atual)", + ) + p.add_argument( + "--with-jail", + action="store_true", + help="legado/opt-in: adicionar a runv-jailed e criar jail em /srv/jail", ) p.add_argument( "--force-gopher", @@ -1917,6 +1923,7 @@ def main(argv: list[str] | None = None) -> int: argv = ["--interactive"] args = parse_args(argv) + args.no_jail = not getattr(args, "with_jail", False) if args.interactive: try: interactive_fill(args) @@ -2040,10 +2047,13 @@ def main(argv: list[str] | None = None) -> int: print( " ações: (1) adduser + skel (2) authorized_keys (3) public_html " "(4) public_gopher + public_gemini + bind Gemini (5) README só com --with-readme " - "(6) permissões (7) jail runv-jailed salvo --no-jail " + "(6) permissões (7) jail SSH só com --with-jail " "(8) quota (9) verificação + patch IRC (chat) (10) metadados JSON" ) - print(f" with-readme: {getattr(args, 'with_readme', False)} no-jail: {getattr(args, 'no_jail', False)}") + print( + f" with-readme: {getattr(args, 'with_readme', False)} " + f"with-jail: {getattr(args, 'with_jail', False)}" + ) if args.no_quota: print(" quota: desativada (--no-quota)") else: @@ -2093,7 +2103,7 @@ def main(argv: list[str] | None = None) -> int: log.info("=== fase 5: permissões consolidadas (home, .ssh, sites públicos, README se existir)") apply_runv_permissions(home, uid, gid) - log.info("=== fase 6: jail SSH (runv-jailed) salvo --no-jail") + log.info("=== fase 6: jail SSH legada (só com --with-jail)") try: runv_jail.ensure_runv_jail_for_user( user, @@ -2210,7 +2220,7 @@ def main(argv: list[str] | None = None) -> int: else: print(" README.md: omitido (use --with-readme para criar)") if args.no_jail: - print(" jail SSH: omitido (--no-jail)") + print(" jail SSH: omitido (padrão; use --with-jail para legado)") else: print(" jail SSH: runv-jailed + /srv/jail/<user> (bind home)") print(f" URL prevista: {args.base_url.rstrip('/')}/~{user}/") diff --git a/scripts/admin/remove_runv_jails.py b/scripts/admin/remove_runv_jails.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +Remove o modelo antigo de jail SSH runv-jailed de membros existentes. + +Desfaz, de forma idempotente, o que ``runv_jail.ensure_runv_jail_for_user`` aplicava: +bind mount em /srv/jail/<user>/home/<user>, linha em /etc/fstab, grupo runv-jailed +e directório /srv/jail/<user>. Também remove o drop-in SSH global da jail. + +Execute como root no servidor Debian. +""" + +from __future__ import annotations + +import argparse +import grp +import logging +import os +import pwd +import subprocess +import sys +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from admin_guard import ensure_admin_cli +import runv_jail + +SSHD_DROPIN = Path("/etc/ssh/sshd_config.d/90-runv-jailed.conf") + + +def setup_logging(verbose: bool) -> logging.Logger: + log = logging.getLogger("remove_runv_jails") + log.setLevel(logging.DEBUG if verbose else logging.INFO) + log.handlers.clear() + h = logging.StreamHandler(sys.stderr) + h.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + log.addHandler(h) + return log + + +def require_root(dry_run: bool, log: logging.Logger) -> None: + if dry_run: + return + if os.geteuid() != 0: + log.error("execute como root (ou use --dry-run)") + raise SystemExit(2) + + +def group_members() -> list[str]: + try: + g = grp.getgrnam(runv_jail.RUNV_JAILED_GROUP) + except KeyError: + return [] + names = set(g.gr_mem) + for pw in pwd.getpwall(): + if pw.pw_gid == g.gr_gid: + names.add(pw.pw_name) + return sorted(n for n in names if not runv_jail.jail_skip_username(n)) + + +def remove_sshd_dropin(*, dry_run: bool, log: logging.Logger) -> None: + if not SSHD_DROPIN.exists(): + log.info("drop-in SSH jail ausente: %s", SSHD_DROPIN) + return + if dry_run: + log.info("[dry-run] removeria %s e recarregaria ssh", SSHD_DROPIN) + return + old_body = SSHD_DROPIN.read_bytes() + SSHD_DROPIN.unlink() + log.info("removido drop-in SSH jail: %s", SSHD_DROPIN) + test = subprocess.run(["sshd", "-t"], capture_output=True, text=True, timeout=30) + if test.returncode != 0: + SSHD_DROPIN.write_bytes(old_body) + err = (test.stderr or test.stdout or "").strip() + raise RuntimeError( + f"sshd -t falhou após remover {SSHD_DROPIN}; ficheiro restaurado: {err}" + ) + for unit in ("ssh", "sshd"): + r = subprocess.run(["systemctl", "reload", unit], capture_output=True, text=True, timeout=60) + if r.returncode == 0: + log.info("systemctl reload %s concluído", unit) + return + log.warning("não foi possível recarregar ssh/sshd automaticamente; recarregue manualmente") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + p = argparse.ArgumentParser(description="Remove runv-jailed e /srv/jail de membros existentes.") + p.add_argument("--dry-run", action="store_true", help="mostra sem alterar") + p.add_argument("--verbose", "-v", action="store_true", help="log detalhado") + p.add_argument("--user", metavar="USER", help="remove jail apenas deste utilizador") + p.add_argument( + "--keep-sshd-dropin", + action="store_true", + help="não remover /etc/ssh/sshd_config.d/90-runv-jailed.conf", + ) + return p.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + ensure_admin_cli(script_name=Path(__file__).name, dry_run=bool(args.dry_run)) + log = setup_logging(args.verbose) + require_root(bool(args.dry_run), log) + + users = [args.user.strip()] if args.user else group_members() + users = [u for u in users if u and not runv_jail.jail_skip_username(u)] + if not users: + log.info("nenhum membro em %s", runv_jail.RUNV_JAILED_GROUP) + for username in users: + try: + pw = pwd.getpwnam(username) + except KeyError: + log.warning("%s não existe em passwd; ignorado", username) + continue + log.info("--- removendo jail de %s", username) + runv_jail.teardown_runv_jail_for_user( + username, + Path(pw.pw_dir), + log, + dry_run=bool(args.dry_run), + ) + + if not args.keep_sshd_dropin: + try: + remove_sshd_dropin(dry_run=bool(args.dry_run), log=log) + except RuntimeError as e: + log.error("%s", e) + return 1 + + log.info("concluído") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/manifests/apt_packages.txt b/tools/manifests/apt_packages.txt @@ -4,9 +4,6 @@ # Debian 13+ (Trixie): o binário /usr/bin/last vem de wtmpdb, não de util-linux. wtmpdb -# Chroot SSH por utilizador (jk_init, etc.) -jailkit - byobu tmux lynx diff --git a/tools/tools.py b/tools/tools.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -runv.club — ferramentas globais, MOTD, Jailkit/SSH runv-jailed, comandos em /usr/local/bin e /etc/skel. +runv.club — ferramentas globais, MOTD, comandos em /usr/local/bin e /etc/skel. Debian 13 · Python 3 stdlib apenas · sem shell=True. Execute como root. Ver docs/05-tools-and-system-experience.md no repositório. @@ -34,7 +34,6 @@ _APT_PACKAGE_ALIASES: dict[str, str] = { BIN_DIR: Path = TOOL_ROOT / "bin" MOTD_SRC: Path = TOOL_ROOT / "motd" / "60-runv" SKEL_DIR: Path = TOOL_ROOT / "skel" -SSHD_DROPIN_SRC: Path = TOOL_ROOT / "sshd" / "90-runv-jailed.conf" SUDOERS_ADMIN_SRC: Path = TOOL_ROOT / "sudoers" / "90-runv-pmurad-admin" DEST_BIN_DIR: Path = Path("/usr/local/bin") @@ -43,7 +42,7 @@ DEST_SKEL: Path = Path("/etc/skel") DEST_SSHD_DROPIN: Path = Path("/etc/ssh/sshd_config.d/90-runv-jailed.conf") DEST_SUDOERS_ADMIN: Path = Path("/etc/sudoers.d/90-runv-pmurad-admin") PATCH_IRC_PATH: Path = TOOL_ROOT.parent / "patches" / "patch_irc.py" -PERM1_PATH: Path = TOOL_ROOT.parent / "scripts" / "admin" / "perm1.py" +REMOVE_JAILS_PATH: Path = TOOL_ROOT.parent / "scripts" / "admin" / "remove_runv_jails.py" @dataclass @@ -303,50 +302,29 @@ def remove_obsolete_skel_readme( log.error("Não foi possível remover %s: %s", stale, e) -def ensure_jailkit_ssh_baseline( +def remove_jail_ssh_baseline( *, - force: bool, dry_run: bool, log: logging.Logger, summary: RunSummary, ) -> None: + """Remove o drop-in antigo de ChrootDirectory para membros runv.""" + if not DEST_SSHD_DROPIN.exists(): + log.info("Drop-in SSH runv-jailed ausente: %s", DEST_SSHD_DROPIN) + summary.skipped.append(str(DEST_SSHD_DROPIN)) + return if dry_run: - log.info("[dry-run] groupadd -f runv-jailed; gpasswd -d pmurad-admin; sshd drop-in; reload ssh") + log.info("[dry-run] removeria %s; testaria sshd -t; recarregaria ssh", DEST_SSHD_DROPIN) + summary.copied.append(f"remover {DEST_SSHD_DROPIN} (simulado)") return - r = subprocess.run( - ["groupadd", "-f", "runv-jailed"], - capture_output=True, - text=True, - timeout=60, - ) - if r.returncode != 0: - err = (r.stderr or r.stdout or "").strip() - msg = f"groupadd -f runv-jailed falhou: {err}" + try: + DEST_SSHD_DROPIN.unlink() + except OSError as e: + msg = f"remover {DEST_SSHD_DROPIN}: {e}" summary.errors.append(msg) log.error("%s", msg) return - log.info("Grupo runv-jailed garantido") - - r = subprocess.run( - ["gpasswd", "-d", "pmurad-admin", "runv-jailed"], - capture_output=True, - text=True, - timeout=60, - ) - if r.returncode != 0: - log.debug("gpasswd -d pmurad-admin (esperado se não estava no grupo): %s", (r.stderr or "").strip()) - - copy_one( - SSHD_DROPIN_SRC, - DEST_SSHD_DROPIN, - 0o644, - force=force, - dry_run=False, - log=log, - summary=summary, - ) - if summary.errors: - return + summary.copied.append(f"removido {DEST_SSHD_DROPIN}") test = subprocess.run( ["sshd", "-t"], @@ -356,7 +334,7 @@ def ensure_jailkit_ssh_baseline( ) if test.returncode != 0: err = (test.stderr or test.stdout or "").strip() - msg = f"sshd -t falhou após instalar drop-in: {err}" + msg = f"sshd -t falhou após remover drop-in runv-jailed: {err}" summary.errors.append(msg) log.error("%s", msg) return @@ -527,19 +505,19 @@ def apply_irc_patch( log.info("patch IRC: %s", r.stdout.strip().splitlines()[-1]) -def apply_jail_backfill( +def remove_existing_jails( *, dry_run: bool, log: logging.Logger, summary: RunSummary, ) -> None: - if not PERM1_PATH.is_file(): - msg = f"perm1.py não encontrado: {PERM1_PATH}" + if not REMOVE_JAILS_PATH.is_file(): + msg = f"remove_runv_jails.py não encontrado: {REMOVE_JAILS_PATH}" summary.errors.append(msg) log.error("%s", msg) return - cmd = [sys.executable, str(PERM1_PATH)] + cmd = [sys.executable, str(REMOVE_JAILS_PATH)] if dry_run: cmd.append("--dry-run") if log.isEnabledFor(logging.DEBUG): @@ -547,20 +525,20 @@ def apply_jail_backfill( r = run_subprocess(cmd, dry_run=False if not dry_run else True, log=log) if dry_run: - summary.copied.append(f"jail backfill (simulado): {' '.join(cmd)}") + summary.copied.append(f"remoção de jails existentes (simulado): {' '.join(cmd)}") return assert r is not None if r.returncode != 0: err = (r.stderr or r.stdout or "").strip() - msg = f"perm1.py falhou (código {r.returncode})" + (f": {err}" if err else "") + msg = f"remove_runv_jails.py falhou (código {r.returncode})" + (f": {err}" if err else "") summary.errors.append(msg) log.error("%s", msg) return - summary.copied.append("jail SSH aplicado/verificado para utilizadores existentes") + summary.copied.append("jails SSH removidas de utilizadores existentes") if r.stdout.strip(): - log.info("perm1.py: %s", r.stdout.strip().splitlines()[-1]) + log.info("remove_runv_jails.py: %s", r.stdout.strip().splitlines()[-1]) def print_summary(summary: RunSummary, log: logging.Logger) -> None: @@ -617,7 +595,12 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace: p.add_argument( "--reconcile-existing-users", action="store_true", - help="reaplica/verifica jail SSH e patch IRC em utilizadores já existentes", + help="reaplica/verifica patch IRC em utilizadores já existentes", + ) + p.add_argument( + "--remove-existing-jails", + action="store_true", + help="remove runv-jailed, binds/fstab e /srv/jail de membros existentes", ) return p.parse_args(argv) @@ -656,9 +639,8 @@ def main(argv: list[str] | None = None) -> int: summary=summary, ) - log.info("Jailkit / SSH runv-jailed (grupo, drop-in, reload)") - ensure_jailkit_ssh_baseline( - force=args.force, + log.info("Removendo baseline antigo de SSH runv-jailed (sem jail por padrão)") + remove_jail_ssh_baseline( dry_run=args.dry_run, log=log, summary=summary, @@ -667,11 +649,11 @@ def main(argv: list[str] | None = None) -> int: log.info("Sincronizando skel em %s", DEST_SKEL) install_skel(force=args.force, dry_run=args.dry_run, log=log, summary=summary) - if args.reconcile_existing_users: - log.info("Aplicando/verificando jail SSH para utilizadores existentes") - apply_jail_backfill(dry_run=args.dry_run, log=log, summary=summary) + if args.remove_existing_jails: + log.info("Removendo jails SSH de utilizadores existentes") + remove_existing_jails(dry_run=args.dry_run, log=log, summary=summary) else: - log.info("Utilizadores existentes não serão alterados (sem --reconcile-existing-users).") + log.info("Jails existentes não serão removidas (sem --remove-existing-jails).") if args.reconcile_existing_users: log.info("Aplicando patch IRC (chat / WeeChat) aos utilizadores existentes")