commit f2732bd78e052572dd2ad915070018980dd162cd
parent e624c32b4da5167da33aed63a5e1c2e15b698b1f
Author: Pablo Murad <pblmrd@gmail.com>
Date: Wed, 13 May 2026 22:05:40 -0300
Mariela Boca Murcha
Diffstat:
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")