Mini Shell
Direktori : /opt/sharedrads/ |
|
Current File : //opt/sharedrads/quarantine |
#!/opt/imh-python/bin/python3
"""Quarantine tool"""
from pathlib import Path
from os import kill, getuid, chown
import re
import pwd
import shlex
import sys
import time
import shutil
import argparse
from argparse import ArgumentTypeError as BadArg
import logging
import logging.handlers
import psutil
import rads
logger = logging.getLogger('quarantine')
USER_RE = re.compile(r'/home[0-9]?/(\w+)')
# EPOCH truncates the minute from the unix timestamp so running this in a loop
# won't create numerous quarantine directories
EPOCH = int(time.time())
EPOCH -= EPOCH % 60
USAGE = """
- PARTIAL: quarantine item1 item2 item3 ...
- FULL: quarantine (--full|-f) user1 user2 ...
- *BOTH: quarantine item1 item2 ... (--full|-f) user1 user2 ...
* Specify items first or else each item will be considered a user"""
DESCRIPTION = """
Relative Paths: If you do not specify the absolute path for a given item, then
it will attempt to create a full path for you. So, the current working
directory is very important. This is because the relative path will be
joined with your current working for directory. For example, running
quarantine public_html from /root will assume that you are trying to
quarantine /root/public_html.
Full Quarantines: The docroots used with full quarantines are found by using the
rads.UserData module. If this fails for some reason, then an error will be
logged. You'll then have to proceed by manually providing the specified
items to be quarantined.
Docroot Recreation: If the actions taken by the quarantine script happen to
quarantine a document root for any domain, then the program will
automatically recreate it.
Specifying Items/Users: There is no limitation on what can be specified as an
item. The script will try to iterate through each item, determine the user,
and quarantine the item as as necessary. If the user or item is invalid, a
warning will be logged and the item will not be quarantined.
Quarantining Subdirectories: When quarantining files, the script creates the
quarantine destination recursively. So, providing the script a subdirectory
item first will cause a File exists error when attempting to quarantine the
top-level directory. Ex: don't do the following:
`quarantine public_html/addondomain.com public_html`
Examples:
quarantine --full userna5
quarantine ~userna5/public_html/addondomain.com ~userna5/subdomain.com
"""
RUN_FIXPERMS = []
def setup_logging(clevel=logging.INFO):
"""Setup logging"""
logger.setLevel(logging.DEBUG)
# stderr
con = logging.StreamHandler(stream=sys.stderr)
con.setLevel(clevel)
con_format = logging.Formatter("%(levelname)s: %(message)s")
con.setFormatter(con_format)
logger.addHandler(con)
# syslog
try:
flog = logging.handlers.SysLogHandler(address='/dev/log')
# syslog is configured to only log INFO and above, so use the same
flog.setLevel(logging.INFO)
flog_format = logging.Formatter("[%(name)s:%(levelname)s] %(message)s")
flog.setFormatter(flog_format)
logger.addHandler(flog)
except Exception as e:
logger.warning("Failed to open logfile: %s", str(e))
def quarantine(path: Path, user_home: Path):
"""Quarantine file/directory"""
# Ex: item = "/home/userna5/public_html"
# Ex: qdest = "/home/userna5/quarantine/quarantine_1538571067/home/userna5/"
# Ex: "/home/userna5/public_html" -> \
# "/home/userna5/quarantine/quarantine_1538571067/home/userna5/public_html"
qdir = user_home / f"quarantine/quarantine_{EPOCH}"
qdest = qdir / str(path.parent).lstrip('/')
try:
qdest.mkdir(mode=0o700, parents=True, exist_ok=True)
except PermissionError:
logger.critical(
"Permission denied to %s: Run fixperms and retry", qdest
)
return
except OSError as exc:
logger.critical("%s: %s", type(exc).__name__, exc)
return
logger.debug("Successfully created quarantine directory (%s).", qdest)
try:
shutil.move(path, qdest)
except Exception as exc:
logger.critical("Error occurred quarantining %s: %s", path, exc)
return
logger.info(
"Successfully quarantined '%s' -> '%s'", path, qdest / path.name
)
def killall(user):
"""Kills all active processes under the passed user"""
procs: dict[int, str] = {}
for pid in psutil.pids():
try:
proc = psutil.Process(pid)
except psutil.NoSuchProcess:
continue
if proc.username() == user:
procs[proc.pid] = shlex.join(proc.cmdline())
killed = []
for pid, cmd in procs.items():
try:
kill(pid, 9)
except OSError as exc:
logging.warning(
"Could not kill %s - %s: %s", cmd, type(exc).__name__, exc
)
continue
killed.append(cmd)
if killed:
print("Killed user processes:", *killed, sep='\n')
def cleanup(user: str, docroots: list[Path]):
"""Miscellaneous cleanup tasks"""
uid = pwd.getpwnam(user).pw_uid
nobody_uid = pwd.getpwnam('nobody').pw_uid
# Iterate through each document root and if it does not
# exist, then re-create it. Since the only root will
# have privileges to set ownership uid:nobody_uid, logic
# exists to skip that step. If the step was skipped, then
# the a WARNING message is shown advising a run of fixperms.
for path in sorted(docroots):
if path.exists():
continue
try:
path.mkdir(parents=True, mode=0o750)
except OSError as exc:
logger.error("Error recreating '%s': %s", path, exc)
continue
logger.debug("Successfully recreated '%s'", path)
# If root, then set proper ownership (user:nobody)
# Else, set flag to notify user they must run fixperms
if UID != 0:
RUN_FIXPERMS.append(user)
continue
try:
chown(path, uid, nobody_uid)
except OSError as e:
logger.error("Error setting ownership for '%s': %s", path, e)
continue
logger.debug("Successfully set ownership for '%s'", path)
def valid_user(user: str):
if not rads.cpuser_safe(user):
raise BadArg(f"{user} is an invalid user")
return user
def valid_path(item: str) -> Path:
try:
path = Path(item)
if not path.resolve().is_relative_to('/home'):
logger.warning(f"{path} is outside /home, skipping.")
return None
if not path.exists():
logger.warning(f"{path} does not exist, skipping.")
return None
return path
except Exception as e:
logger.warning(f"Error validating {item}: {e}, skipping.")
return None
def item_validation(path: Path, user_home: Path):
"""Test for valid item"""
if not path.is_relative_to(user_home):
logger.warning("Not quarantining item (%s) outside homedir", path)
elif not path.exists():
logger.warning("%s wasn't found", path)
elif path == user_home:
logger.warning("Not quarantining user's homedir (%s)", path)
elif not str(path).startswith("/home"):
logger.warning("Quarantining is restricted to /home")
else:
return True
return False
def get_userdata(user: str, all_roots: bool = False) -> list[Path]:
"""Gets docroots from rads.Userdata"""
try:
if all_roots:
docroots = map(Path, rads.UserData(user).all_roots)
else:
docroots = map(Path, rads.UserData(user).merged_roots)
home = Path(f"~{user}").expanduser()
except Exception as exc:
logger.error("Error retrieving %s's cPanel userdata: %s", user, exc)
return []
return [
x
for x in docroots
if x.is_relative_to(home) and str(x).startswith('/home')
]
def parse_args() -> tuple[set[Path], bool]:
"""Parse Arguments"""
parser = argparse.ArgumentParser(
formatter_class=argparse.RawTextHelpFormatter,
usage=USAGE,
description=DESCRIPTION,
)
parser.set_defaults(loglevel=logging.INFO, items=[], users=[])
parser.add_argument(
"items",
nargs='*',
help="files and directories to quarantine",
type=valid_path,
)
parser.add_argument(
"-f",
"--full",
dest='users',
nargs="+",
type=valid_user,
help="Perform a full quarantine of all docroots for given users",
)
parser.add_argument(
"-d",
'--debug',
dest='loglevel',
action='store_const',
const=logging.DEBUG,
help="Enable debug output",
)
parser.add_argument(
"-k",
'--kill',
dest='kill',
action='store_true',
help="Kill all processes under the detected user after quarantine",
)
args = parser.parse_args()
setup_logging(args.loglevel)
items: list[Path] = [item for item in args.items if item is not None]
for user in args.users: # for any user supplied with --full
items.extend(get_userdata(user))
items = {x.resolve() for x in items}
return items, args.kill
def main():
"""Main Logic"""
paths, do_kill = parse_args()
user_docroots: dict[str, list[Path]] = {}
# For each item: parse user, validate user, store userdata, set
# homedir, validate item, quarantine item. The contents of items can include
# files/directories across multiple users
for path in paths:
if user_match := USER_RE.match(str(path)):
user = user_match.group(1)
else:
logger.warning("Unable to determine user for %s", path)
continue
if not rads.cpuser_safe(user):
logger.warning('Skipping invalid user %s', user)
continue
if user not in user_docroots:
user_docroots[user] = get_userdata(user, all_roots=True)
try:
user_home = Path(f"~{user}").expanduser()
except Exception:
logger.warning("Could not get homedir for %s", user)
continue
if not item_validation(path, user_home):
logger.warning("Skipping %s", path)
continue
quarantine(path, user_home)
if do_kill:
killall(user)
# This block of code calls the cleanup function.
# It currently just ensures that all the user's docroots are present
for user, docroots in user_docroots.items():
cleanup(user, docroots)
if not RUN_FIXPERMS:
return
logger.warning(
"During the quarantine process some document roots were quarantined. "
"You must run fixperms to correct permissions/ownership of these "
"document roots. Please run: fixperms %s",
shlex.join(RUN_FIXPERMS),
)
if __name__ == '__main__':
UID = getuid()
try:
main()
except KeyboardInterrupt:
print("Canceled.")
Zerion Mini Shell 1.0