Mini Shell
Direktori : /opt/sharedrads/ |
|
Current File : //opt/sharedrads/suspend_user |
#!/opt/imh-python/bin/python3
import grp
import sys
import re
import pwd
import os
import time
import locale
import subprocess
import syslog
import platform
from pathlib import Path
from argparse import ArgumentParser
from typing import Literal, Union
from pp_api import PowerPanel
from rads.color import red
import rads
pp = PowerPanel()
# Set locale so that the time zone will be logged properly
locale.setlocale(locale.LC_TIME, '')
LOCK_FILE = Path('/var/lock/suspend_user')
LOCK_WAIT = 30 # Seconds to wait to acquire a lock
LOG_FILE = '/var/log/suspension.log' # Suspension log
PROCNAME = Path(sys.argv[0]).name
# Are we suspending or unsuspending? "unsuspend_user" is a symlink
# to this script
SUSPEND = 'unsuspend' not in PROCNAME
# Is the script being run from a terminal?
TTY = sys.stdout.isatty()
REASON_LIST = (
'security',
'ra',
'moved',
'billing',
'canceled',
'tos',
'legal',
'orphan',
'donotterm',
'other',
)
def get_groups(user: str) -> list[str]:
groups = [g.gr_name for g in grp.getgrall() if user in g.gr_mem]
gid = pwd.getpwnam(user).pw_gid
groups.append(grp.getgrgid(gid).gr_name)
return groups
def non_root_safe(user):
if SUSPEND:
return
user = rads.get_login()
if user == 'root':
return
groups = get_groups(user)
if (
'tier2s@ipa.imhtech.net' in groups
or 'managedhosting@ipa.imhtech.net' in groups
):
return
legit_unsuspend = ['billing', 'moved', 'orphan', 'canceled']
path = Path('/var/cpanel/suspended', user.lstrip('/'))
if not path.is_file():
return
try:
data = path.read_text(encoding='utf-8')
except FileNotFoundError:
return
try:
suspension_reason = data.splitlines()[0].split(':', maxsplit=1)[0]
except (IndexError, ValueError):
sys.exit(
'Unable to determine the reason for suspension. '
'Please consult tier2s.'
)
issues = [
match for match in legit_unsuspend if match in suspension_reason.lower()
]
if not issues:
sys.exit(
"Only root allowed to unsuspend people that are suspended "
f"for {suspension_reason}"
)
def print_red(msg: str):
if TTY:
print(red(msg))
else:
print(msg)
def logdate():
"""Returns the current date and time in a format suitable for
inclusion in a log file"""
return time.strftime("%Y-%m-%d:%H:%M:%S %Z", time.localtime(time.time()))
def log_suspension(
user: str,
susp_type: str,
caller: str,
reason: str,
duration: str,
comment: Union[str, None],
):
"""Logs account suspension and unsuspension events"""
if not comment:
comment = '-'
blame = get_calling_username()
susp_type = susp_type.upper()
entry = f'{user} [{susp_type}] {reason} {duration} {blame} "{comment}"'
syslog.openlog(PROCNAME)
syslog.syslog(entry)
acquire_lock()
with open(LOG_FILE, 'a', encoding='utf-8') as file:
file.write(f'{logdate()} {os.path.basename(caller)}: {entry}\n')
LOCK_FILE.unlink(missing_ok=True)
def get_calling_username():
try:
blame = f'{os.getlogin()}:{pwd.getpwuid(os.geteuid()).pw_name}'
except OSError:
blame = pwd.getpwuid(os.geteuid()).pw_name
return blame
def send_suspension_email(user: str, comment: str, is_temp=False, duration=0):
"""Send suspension email"""
# IMH - Normal suspension id 7
# IMH - Temp suspension id 135 variable1 == duration
# WHH - Normal suspension id 322
# WHH - Temp suspension id 392 variable1 == duration
# Reseller - Child acct suspension id 515 variable1 == user
# Reseller - Child account temp suspension id 516 variable1 == duration,
# variable2 == user
if 'hub' in platform.node():
template_id = 392 if is_temp else 322
send_to = user
else:
# If the customer is the child of a reseller, use the
# reseller template instead
owner = rads.get_owner(user)
if owner not in rads.OUR_RESELLERS:
template_id = 516 if is_temp else 515
send_to = owner
else:
template_id = 135 if is_temp else 7
send_to = user
duration = duration / 60
template_info = pp.call("notification.fetch-template", template=template_id)
if template_info.status == 0:
variables = {}
for variable in template_info.data['variables']:
if variable['description'] == "Child User":
variables[variable['name']] = user
else:
variables[variable['name']] = "%s minutes" % duration
response = pp.call(
"notification.send",
template=template_id,
cpanelUser=send_to,
**variables,
)
if response.status == 0:
print("Sent email, review at %s" % response.data['reviewUrl'])
logged_in_user = get_calling_username().split(':')[0]
if logged_in_user == "root":
reporter = "auto"
else:
reporter = logged_in_user
pp(
'hosting.insert-note',
user=user,
admin_user=reporter,
flagged=True,
type='Suspension',
# Prepend user to the note because the hosting.insert-note
# endpoint doesn't seem to honor the 'admin_user' parameter
# This issue is tracked in Devel #4775
# https://trac.imhtech.net/Development/ticket/4775
note=f'{reporter}: {comment}',
)
return
print_red(
"Could not send suspension email or note acct, please do this manually!"
)
def suspend_unsuspend(args):
"""Suspends or unsuspends based on the value of the global SUSPEND"""
if SUSPEND:
action = 'suspend'
cmd = [f"/scripts/{action}acct", args.user]
cmd.append(f'{args.reason}:{args.comment}')
if args.lock or not args.log_only:
cmd.append("1") # lock
else:
action = 'unsuspend'
cmd = [f"/scripts/{action}acct", args.user]
try:
subprocess.check_call(cmd, env={'RADSSUSPEND': 'True'})
except (OSError, subprocess.CalledProcessError):
print(f'WARNING: Account may not have been properly {action}ed!')
def suspend_special(args, type_str: Literal['suspended', 'autosuspend']):
"""
Takes the option autosuspend or suspended (the path where sharedrads expects
to find files for scheduled and temp suspensions
"""
# Sched and temp suspensions expect slightly different data
if type_str == 'suspended':
data = args.duration_str
else:
data = args.reason.lower()
if not os.path.exists('/opt/sharedrads/%s' % type_str):
os.mkdir('/opt/sharedrads/%s' % type_str)
path = Path('/opt/sharedrads', type_str, args.user)
with open(path, 'w', encoding='utf-8') as file:
# write future timestamp
file.write(f'{args.duration_secs + int(time.time())} {data}\n')
def archive_logs(user: str):
user_pwd = pwd.getpwnam(user)
dotlogs_file = os.path.join('/home', user, '.cpanel-logs')
with open(dotlogs_file, 'w', encoding='utf-8') as logpref_file:
logpref_file.write('archive-logs=1\nremove-old-archived-logs=1\n')
os.chown(dotlogs_file, user_pwd.pw_uid, user_pwd.pw_gid)
def acquire_lock():
if not LOCK_FILE.exists():
LOCK_FILE.write_text(str(os.getpid()), encoding='ascii')
return
pid = int(LOCK_FILE.read_text(encoding='ascii'))
try:
cmdline = Path('/proc', str(pid), 'cmdline').read_text(encoding='ascii')
except OSError:
print(f"Stale lock file found, but {pid} isn't running. Moving on...")
LOCK_FILE.write_text(str(os.getpid()), encoding='ascii')
return
# Hard code suspend_user because sys.argv[0] could be unsuspend_user
if 'suspend_user' not in cmdline:
print(
f"Stale lock file found, but {pid}",
"isn't an instance of this script. Moving on...",
)
LOCK_FILE.write_text(str(os.getpid()), encoding='ascii')
return
expired = False
print("Waiting for account suspension lock", end=' ')
for _ in range(LOCK_WAIT):
print('.', end=' ', flush=True)
time.sleep(1)
if not LOCK_FILE.exists():
expired = True
print("Process", pid, "released its lock")
break
if not expired:
print(
f"Process {pid} hasn't released its lock.",
"Stealing the lock file forcibly",
)
LOCK_FILE.write_text(str(os.getpid()), encoding='ascii')
def parse_args():
if SUSPEND:
parser = ArgumentParser(
description='Suspend user',
usage="%(prog)s username [duration|delay] [options] "
"[--sched|-s] [--lock|-l] -r reason",
)
else:
parser = ArgumentParser(
description='Unsuspend user',
usage='%(prog)s username [options]',
)
# fmt: off
parser.add_argument(
"-c", "--comment", dest="comment",
help="additional comment to place in the suspension log",
)
if not TTY:
parser.add_argument("--info", action="store_true", dest="info")
parser.add_argument("--invoked-by", dest="caller", default="unknown")
if SUSPEND:
parser.add_argument(
"-s", "--sched", action="store_true", dest="sched",
help="do not suspend immediately; schedule a suspension "
"to occur later",
)
parser.add_argument(
"-l", "--lock", action="store_const", const="lock", dest="lock",
help="lock the suspension: non-root reseller cannot unsuspend",
)
reason_group = parser.add_mutually_exclusive_group(required=True)
reason_group.add_argument(
"-r", "--reason", dest="reason", choices=REASON_LIST,
help="reason for the suspension",
)
reason_group.add_argument(
"--ra", action="store_const", const="ra", dest="reason",
help="suspended for RA",
)
reason_group.add_argument(
"--moved", "--move", action="store_const",
const="moved", dest="reason",
help="account was moved",
)
reason_group.add_argument(
"--billing", action="store_const", const="billing", dest="reason",
help="suspended for billing purposes",
)
reason_group.add_argument(
"--canceled", action="store_const", const="canceled", dest="reason",
help="suspended because of cancellation",
)
reason_group.add_argument(
"--tos", action="store_const", const="tos", dest="reason",
help="suspended for ToS violation",
)
reason_group.add_argument(
"--legal", action="store_const", const="legal", dest="reason",
help="suspended for legal reasons",
)
reason_group.add_argument(
"--security", action="store_const", const="security", dest="reason",
help="suspended for security violation",
)
reason_group.add_argument(
"--orphan", action="store_const", const="orphan", dest="reason",
help="account is orphaned",
)
reason_group.add_argument(
"--donotterm", action="store_const",
const="donotterm", dest="reason",
help="do not terminate account",
)
# fmt: on
args, extras = parser.parse_known_args()
if TTY:
args.info = None
args.caller = None
args.user = None
args.log_only = False
args.duration_secs = None
args.duration_str = ''
# Regular expression that must be valid for temp or scheduled suspensions
time_re = re.compile(r'^(\d+)([dhm])$')
# Number of seconds in a minute, hour, day
secs = {'m': 60, 'h': 3600, 'd': 86400}
# loop through positional args
for pos_arg in extras:
try:
uid = pwd.getpwnam(pos_arg).pw_uid
except Exception:
uid = None
# If string exists in the password database and is a cPanel user
# Assign as the user name to act upon
if uid is not None and os.path.exists(f'/var/cpanel/users/{pos_arg}'):
args.user = pos_arg
# If string matches the time regexp set it as args.duration_*
elif match := time_re.match(pos_arg):
dur, unit = match.groups()
args.duration_secs = int(dur) * secs[unit]
args.duration_str = pos_arg
elif 'log_only' in pos_arg:
args.log_only = True
# If string doesn't match assume it's the comment
else:
args.comment = pos_arg
# Require a valid user
if args.user is None:
print("ERROR: No valid user specified")
sys.exit(1)
return args
def main():
args = parse_args()
if args.caller:
invoking_process = args.caller
else:
invoking_process = PROCNAME
if args.log_only and 'RADSSUSPEND' in os.environ:
# Don't actually suspend, just update the suspension log, this
# condition is meant to be triggered by cPanel suspension hooks
if SUSPEND:
log_suspension(
user=args.user,
susp_type='cp_suspension',
caller=invoking_process,
reason='-',
duration='perm',
comment=args.comment,
)
archive_logs(args.user)
else:
log_suspension(
user=args.user,
susp_type='cp_unsuspension',
caller=invoking_process,
reason='-',
duration='-',
comment=args.comment,
)
archive_logs(args.user)
elif SUSPEND:
# If invoked with the --info option just add a note
# Only maint scripts should call suspend_user in this fashion
if args.reason.lower() == "donotterm" and not args.comment:
args.comment = "Marked as Do Not Terminate"
if not TTY and args.info:
log_suspension(
user=args.user,
susp_type='info',
caller=invoking_process,
reason=args.reason,
duration='-',
comment=args.comment,
)
# Is the user already suspended?
elif not rads.cpuser_suspended(args.user):
# Display an error and exit if a scheduled suspension is chosen
# but d is None
if args.duration_secs is None and args.sched:
print("ERROR: Scheduled suspensions require a valid delay!")
sys.exit(1)
# Temp suspension
elif args.duration_secs is not None and not args.sched:
print_red(
f'Suspending {args.user} (reason: {args.reason}) '
f'for {args.duration_secs} seconds ({args.duration_str}) '
f'with comment "{args.comment}"'
)
if args.reason.lower() == "ra":
if not args.comment:
print_red(
"Account note is required for RA suspensions! "
"Not suspending user!"
)
sys.exit(1)
send_suspension_email(
args.user,
args.comment,
is_temp=True,
duration=args.duration_secs,
)
log_suspension(
user=args.user,
susp_type='suspension',
caller=invoking_process,
reason=args.reason,
duration='temp:%s' % args.duration_str,
comment=args.comment,
)
suspend_special(args, 'suspended')
suspend_unsuspend(args)
archive_logs(args.user)
# Sched suspension
elif args.duration_secs is not None and args.sched:
print_red(
f'Suspending {args.user} (reason: {args.reason}) '
f'{args.duration_secs} seconds ({args.duration_str}) '
f'from now with comment "{args.comment}"'
)
log_suspension(
user=args.user,
susp_type='sched_suspension',
caller=invoking_process,
reason=args.reason,
duration='sched:%s' % args.duration_str,
comment=args.comment,
)
suspend_special(args, 'autosuspend')
# Auto suspension
elif not TTY and 'autosuspend' in args.caller:
log_suspension(
user=args.user,
susp_type='auto_suspension',
caller=invoking_process,
reason=args.reason,
duration='perm',
comment=args.comment,
)
suspend_unsuspend(args)
archive_logs(args.user)
# Perm suspension
elif args.duration_secs is None and not args.sched:
print_red(
f'Suspending {args.user} (reason: {args.reason}) '
f'permanently with comment "{args.comment}"'
)
if args.reason.lower() == "ra":
if not args.comment:
print_red(
"Account note is required for RA suspensions! "
"Not suspending user!"
)
sys.exit(1)
send_suspension_email(
args.user, args.comment, is_temp=False
)
log_suspension(
user=args.user,
susp_type='suspension',
caller=invoking_process,
reason=args.reason,
duration='perm',
comment=args.comment,
)
suspend_unsuspend(args)
archive_logs(args.user)
else:
print("ERROR: %s already appears to be suspended!" % args.user)
sys.exit(1)
else:
if rads.cpuser_suspended(args.user):
non_root_safe(args.user)
print_red(
'Unsuspending {} with comment "{}"'.format(
args.user, args.comment
)
)
log_suspension(
user=args.user,
susp_type='unsuspension',
caller=invoking_process,
reason='-',
duration='-',
comment=args.comment,
)
suspend_unsuspend(args)
try:
os.unlink('/opt/sharedrads/suspended/%s' % args.user)
print_red('Removed RADS temp suspension file')
except Exception:
pass
else:
print("ERROR: %s does not appear to be suspended!" % args.user)
sys.exit(1)
if __name__ == '__main__':
main()
Zerion Mini Shell 1.0