diff --git a/Makefile b/Makefile index d97aa4e..412bf77 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,10 @@ install: @mkdir -p $(BINDIR) @mkdir -p $(ZNCCONF) @install -m 755 makeuser $(BINDIR) + @install -m 755 rmuser $(BINDIR) @install -m 644 welcome-email.tmpl $(BINDIR) @install -m 700 znccreate.py $(BINDIR) + @install -m 700 zncdelete.py $(BINDIR) @install -m 600 znc-config-ex.json $(ZNCCONF) @echo Remember to edit znc-config with your ZNC details and rename $(ZNCCONF)/znc-config-ex.json to $(ZNCCONF)/znc-config.json @echo ENJOY @@ -15,8 +17,10 @@ install: uninstall: @echo removing the executables from $(BINDIR) @rm -f $(BINDIR)/makeuser + @rm -f $(BINDIR)/rmuser @rm -f $(BINDIR)/welcome-email.tmpl @rm -f $(BINDIR)/znccreate.py + @rm -f $(BINDIR)/zncdelete.py @echo znc-config.json has not been touched. You will need to manually remove it from $(ZNCCONF) .PHONY: install uninstall diff --git a/README.md b/README.md index 0fe3dd8..38a4011 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,17 @@ A script that allows admins to make user accounts easily. Run `make` to install the script to `/usr/local/bin`. + + +## User removal + +Use `rmuser ` to remove a user account and clean up related services: + +- Removes user from Helpdesk (`helpdesk_admin.sh del`) +- Removes user from ZNC (`zncdelete.py`) +- Discovers user email from `/var/signups` for Helpdesk/list cleanup +- Comments out matching signup entries in `/var/signups` +- Sends mailing list unsubscribe request when an email is discovered +- Deletes the Unix account and home directory (`userdel -r`) + +Run `make` to install `rmuser` and `zncdelete.py` to `/usr/local/bin`. diff --git a/makeuser b/makeuser index 8aebc9c..2c9a1ba 100755 --- a/makeuser +++ b/makeuser @@ -5,7 +5,9 @@ # --------------------------------------------------------------------------- PROGNAME=${0##*/} -VERSION="0.2" +VERSION="0.3" +CLUB_GROUP_ID="100" +WELCOME_EMAIL_TEMPLATE="/usr/local/bin/welcome-email.tmpl" error_exit() { printf "%s: %s\n" "$PROGNAME" "${1:-"Unknown Error"}" >&2 @@ -23,11 +25,97 @@ Subject: subscribe MAIL } -case $1 in +send_welcome_mail() { + welcome_username=$1 + welcome_password=$2 + welcome_email=$3 + + if [ ! -r "$WELCOME_EMAIL_TEMPLATE" ]; then + error_exit "welcome email template is not readable" + fi + + { + printf "%s\n" "$welcome_username" + printf "%s\n" "$welcome_email" + printf "%s\n" "$welcome_password" + } | python3 -c ' +import re +import sys + +template_path = "/usr/local/bin/welcome-email.tmpl" + +username = sys.stdin.readline().rstrip("\r\n") +email = sys.stdin.readline().rstrip("\r\n") +password = sys.stdin.readline().rstrip("\r\n") + +if not username or not email or not password: + sys.stderr.write("missing username, email, or password\n") + sys.exit(1) + +if "\n" in username or "\r" in username or "\n" in email or "\r" in email: + sys.stderr.write("unsafe username or email value\n") + sys.exit(1) + +with open(template_path, "r", encoding="utf-8", errors="replace") as handle: + text = handle.read() + +text = text.replace("newusername", username) +text = text.replace("newpassword", password) +text = text.replace("newtoemail", email) + +lines = text.splitlines(True) + +has_header_block = bool(lines and re.match(r"^[A-Za-z0-9-]+:", lines[0])) + +if has_header_block: + header_end = None + + for index, line in enumerate(lines): + if line.rstrip("\r\n") == "": + header_end = index + break + + if header_end is None: + header_end = len(lines) + header_lines = lines + body_lines = ["\n"] + else: + header_lines = lines[:header_end] + body_lines = lines[header_end:] + + has_recipient_header = any( + re.match(r"^(to|cc|bcc):", header, re.IGNORECASE) + for header in header_lines + ) + + output = [] + + output.extend(header_lines) + + if not has_recipient_header: + output.append(f"To: {email}\n") + + output.append(f"Bcc: {username}, root@tilde.club\n") + output.extend(body_lines) + + sys.stdout.write("".join(output)) +else: + sys.stdout.write(f"To: {email}\n") + sys.stdout.write(f"Bcc: {username}, root@tilde.club\n") + sys.stdout.write("\n") + sys.stdout.write(text) +' | sendmail -oi -t +} + +case ${1:-} in -h | --help) - usage; exit ;; + usage + exit + ;; -* | --*) - usage; error_exit "unknown option $1" ;; + usage + error_exit "unknown option $1" + ;; *) if [ $# -ne 3 ]; then error_exit "not enough args" @@ -39,22 +127,28 @@ case $1 in printf "adding new user %s\n" "$1" newpw=$(pwgen -1B 20) - sudo useradd -m -g 100 -s /bin/bash "$1" \ + trap 'unset newpw' 0 HUP INT TERM + + printf "creating matching primary group for %s\n" "$1" + if ! getent group "$1" > /dev/null 2>&1; then + sudo groupadd "$1" || error_exit "couldn't add group" + fi + + sudo useradd -m -g "$1" -G "$CLUB_GROUP_ID" -s /bin/bash "$1" \ || error_exit "couldn't add user" - printf "%s:%s\n" "$1" "$newpw" | sudo chpasswd + + printf "%s:%s\n" "$1" "$newpw" | sudo chpasswd \ + || error_exit "couldn't set user password" printf "sending welcome mail\n" - sed -e "s/newusername/$1/g" \ - -e "s/newpassword/$newpw/" \ - -e "s/newtoemail/$2/" \ - /usr/local/bin/welcome-email.tmpl \ - | sendmail "$1" "$2" root@tilde.club + send_welcome_mail "$1" "$newpw" "$2" \ + || error_exit "couldn't send welcome mail" printf "subscribing to mailing list\n" sub_to_list "$1" printf "adding ssh pubkey\n" - printf "%s\n" "$3" | sudo tee "/home/$1/.ssh/authorized_keys" + printf "%s\n" "$3" | sudo tee "/home/$1/.ssh/authorized_keys" > /dev/null printf "\nannouncing new user on mastodon\n" /usr/local/bin/toot "welcome new user ~$1!" @@ -63,10 +157,10 @@ case $1 in sudo sed -i"" "/\b$1\b/d" /var/signups_current printf "removing .git from new homedir\n" - sudo rm -rf /home/$1/.git + sudo rm -rf "/home/$1/.git" printf "removing skel git README.md\n" - sudo rm /home/$1/README.md + sudo rm "/home/$1/README.md" printf "fix sorting in /etc/passwd\n" sudo pwck -s @@ -75,11 +169,15 @@ case $1 in sudo xfs_quota -x -c "limit -u bsoft=1G bhard=3G $1" printf "making znc user\n" - /usr/local/bin/znccreate.py "$1" "$newpw" "MaxNetworks=3" + printf "%s\n" "$newpw" | /usr/local/bin/znccreate.py "$1" --password-stdin "MaxNetworks=3" \ + || error_exit "couldn't add znc user" + + unset newpw + trap - 0 HUP INT TERM printf "Adding user to Helpdesk\n" - /usr/local/bin/helpdesk_admin.sh "add" "$1" "$2" + printf "%s\n" "$2" | /usr/local/bin/helpdesk_admin.sh "add" "$1" --email-stdin \ + || error_exit "couldn't add user to Helpdesk" + ;; esac - - diff --git a/rmuser b/rmuser new file mode 100755 index 0000000..9ba788c --- /dev/null +++ b/rmuser @@ -0,0 +1,160 @@ +#!/bin/sh +# --------------------------------------------------------------------------- +# rmuser - tilde.club user removal helper +# Usage: rmuser [-h|--help] +# --------------------------------------------------------------------------- + +PROGNAME=${0##*/} +VERSION="0.2" +SIGNUPS_FILE="/var/signups" + +error_exit() { + printf "%s: %s\n" "$PROGNAME" "${1:-"Unknown Error"}" >&2 + exit 1 +} + +usage() { + printf "usage: %s %s [-h|--help] \n" "$PROGNAME" "$VERSION" +} + +lookup_email_from_signups() { + user="$1" + + if [ ! -r "$SIGNUPS_FILE" ]; then + printf "warning: cannot read %s to discover email\n" "$SIGNUPS_FILE" >&2 + return 0 + fi + + awk -v u="$user" ' + /^[[:space:]]*#/ { next } + { + n = split($0, fields, /[\t ,;:]+/) + found_user = 0 + for (i = 1; i <= n; i++) { + if (fields[i] == u) { + found_user = 1 + } + } + if (found_user) { + for (i = 1; i <= n; i++) { + if (fields[i] ~ /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/) { + print fields[i] + exit + } + } + } + } + ' "$SIGNUPS_FILE" +} + +comment_out_signup() { + user="$1" + + if [ ! -f "$SIGNUPS_FILE" ]; then + printf "warning: %s does not exist, skipping signup comment\n" "$SIGNUPS_FILE" >&2 + return 0 + fi + + if ! sudo test -w "$SIGNUPS_FILE"; then + printf "warning: %s is not writable, skipping signup comment\n" "$SIGNUPS_FILE" >&2 + return 0 + fi + + tmpfile=$(mktemp) || return 1 + + awk -v u="$user" ' + /^[[:space:]]*#/ { + print + next + } + { + line = $0 + n = split($0, fields, /[\t ,;:]+/) + found_user = 0 + for (i = 1; i <= n; i++) { + if (fields[i] == u) { + found_user = 1 + } + } + if (found_user) { + print "# " line + } else { + print + } + } + ' "$SIGNUPS_FILE" > "$tmpfile" || { + rm -f "$tmpfile" + return 1 + } + + sudo cp "$tmpfile" "$SIGNUPS_FILE" || { + rm -f "$tmpfile" + return 1 + } + + rm -f "$tmpfile" +} + +maybe_unsubscribe_list() { + email="$1" + + if [ -z "$email" ]; then + printf "skipping mailing list unsubscribe (email not found)\n" + return 0 + fi + + printf "sending mailing list unsubscribe request\n" + sendmail tildeclub-join@lists.tildeverse.org << MAIL || return 1 +From: $email +Subject: unsubscribe +MAIL +} + +case $1 in + -h | --help) + usage; exit 0 ;; + -* | --*) + usage; error_exit "unknown option $1" ;; + *) + if [ $# -ne 1 ]; then + usage + error_exit "invalid args" + fi + + user="$1" + + if ! id "$user" > /dev/null 2>&1; then + error_exit "user $user does not exist" + fi + + email=$(lookup_email_from_signups "$user") + if [ -n "$email" ]; then + printf "found email for %s: %s\n" "$user" "$email" + else + printf "warning: email for %s not found in %s\n" "$user" "$SIGNUPS_FILE" >&2 + fi + + printf "commenting out %s from %s\n" "$user" "$SIGNUPS_FILE" + comment_out_signup "$user" \ + || printf "warning: failed to comment out %s in %s\n" "$user" "$SIGNUPS_FILE" >&2 + + printf "removing user from Helpdesk\n" + /usr/local/bin/helpdesk_admin.sh "del" "$user" \ + || printf "warning: failed to remove %s from Helpdesk\n" "$user" >&2 + + printf "removing user from ZNC\n" + /usr/local/bin/zncdelete.py "$user" \ + || printf "warning: failed to remove %s from ZNC\n" "$user" >&2 + + maybe_unsubscribe_list "$email" \ + || printf "warning: failed to send mailing list unsubscribe\n" >&2 + + printf "deleting unix account and home directory\n" + sudo userdel -r "$user" || error_exit "couldn't delete user" + + printf "fix sorting in /etc/passwd\n" + sudo pwck -s || printf "warning: pwck -s reported an issue\n" >&2 + + printf "done removing %s\n" "$user" + ;; +esac diff --git a/znccreate.py b/znccreate.py index d074992..9de2f69 100644 --- a/znccreate.py +++ b/znccreate.py @@ -1,75 +1,152 @@ #!/usr/bin/python3.8 # Script created/contributed by ~jmjl -import socket, ssl, json, time, sys +import json +import socket +import ssl +import sys +import time + + +CONFIG_FILE = "/root/.znc-conf/znc-config.json" + + +def error_exit(message): + print(f"{sys.argv[0]}: {message}", file=sys.stderr) + sys.exit(1) + + +def usage(): + print( + "usage:\n" + f" {sys.argv[0]} [Key=Value ...]\n" + f" {sys.argv[0]} --password-stdin [Key=Value ...]", + file=sys.stderr, + ) + def loadconf(cfgfile): - with open(cfgfile, 'r') as f: + with open(cfgfile, "r") as f: return json.load(f) -def send(msg): - s.send(f"{msg}\n".encode('utf-8')) -cfg = loadconf("/root/.znc-conf/znc-config.json") +def read_password_from_stdin(): + password = sys.stdin.readline() -readbuffer = "" -s = socket.socket() -if cfg.get('tls') == 'yes': - ctx = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) - s = ctx.wrap_socket(s) + if password == "": + error_exit("no password received on stdin") -s.connect((cfg['srv'], int(cfg['port']))) -send("NICK bot") -send("USER bot 0 * :A bot to make users") + password = password.rstrip("\r\n") -# Parse optional key=value settings after username/password. -# Example: MaxNetworks=3 MaxClients=5 -cli_settings = {} -for arg in sys.argv[3:]: - if '=' in arg: - k, v = arg.split('=', 1) - k = k.strip() - v = v.strip() - if k: - cli_settings[k] = v + if password == "": + error_exit("empty password received on stdin") -# Also allow defaults from config, but CLI wins. -# In /root/.znc-conf/znc-config.json you may add: -# { ..., "default_user_settings": { "MaxNetworks": "3" } } -default_settings = cfg.get("default_user_settings", {}) or {} + return password -while True: - readbuffer += s.recv(2048).decode('utf-8', errors='ignore') - temp = str.split(readbuffer, "\n") - readbuffer = temp.pop() - for line in temp: - line = line.rstrip("\r") - parts = line.split() +def parse_args(argv): + if len(argv) < 3: + usage() + error_exit("not enough arguments") - if len(parts) < 2: - continue + user = argv[1] - # Authenticate when ZNC asks for PASS (ERR_PASSWDMISMATCH 464). - if parts[1] == '464': - send(f"PASS {cfg['user']}:{cfg['password']}") + if not user: + error_exit("username cannot be empty") - # On welcome (001), create user and apply settings. - # (Preserves your original hostname check.) - if parts[0][1:] == 'irc.znc.in' and parts[1] == '001': - user = sys.argv[1] - pswd = sys.argv[2] + if argv[2] == "--password-stdin": + password = read_password_from_stdin() + settings_args = argv[3:] + else: + password = argv[2] + settings_args = argv[3:] - # Create user (unchanged) - send(f"PRIVMSG *controlpanel :AddUser {user} {pswd}") + if not password: + error_exit("password cannot be empty") - # Merge defaults + CLI, CLI overrides - effective = dict(default_settings) - effective.update(cli_settings) + cli_settings = {} - # Apply settings like: set MaxNetworks - for key, val in effective.items(): - send(f"PRIVMSG *controlpanel :set {key} {user} {val}") + # Parse optional key=value settings after username/password. + # Example: + # MaxNetworks=3 MaxClients=5 + for arg in settings_args: + if "=" in arg: + key, value = arg.split("=", 1) + key = key.strip() + value = value.strip() - print(f"Maken znc user {user}") - sys.exit(0) + if key: + cli_settings[key] = value + + return user, password, cli_settings + + +def send(sock, msg): + sock.send(f"{msg}\n".encode("utf-8")) + + +def main(): + user, pswd, cli_settings = parse_args(sys.argv) + + cfg = loadconf(CONFIG_FILE) + + # Also allow defaults from config, but CLI wins. + # In /root/.znc-conf/znc-config.json you may add: + # { + # ..., + # "default_user_settings": { + # "MaxNetworks": "3" + # } + # } + default_settings = cfg.get("default_user_settings", {}) or {} + + readbuffer = "" + + sock = socket.socket() + + if cfg.get("tls") == "yes": + ctx = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + sock = ctx.wrap_socket(sock) + + sock.connect((cfg["srv"], int(cfg["port"]))) + + send(sock, "NICK bot") + send(sock, "USER bot 0 * :A bot to make users") + + while True: + readbuffer += sock.recv(2048).decode("utf-8", errors="ignore") + temp = str.split(readbuffer, "\n") + readbuffer = temp.pop() + + for line in temp: + line = line.rstrip("\r") + parts = line.split() + + if len(parts) < 2: + continue + + # Authenticate when ZNC asks for PASS (ERR_PASSWDMISMATCH 464). + if parts[1] == "464": + send(sock, f"PASS {cfg['user']}:{cfg['password']}") + + # On welcome (001), create user and apply settings. + # Preserves your original hostname check. + if parts[0][1:] == "irc.znc.in" and parts[1] == "001": + # Create user. + send(sock, f"PRIVMSG *controlpanel :AddUser {user} {pswd}") + + # Merge defaults + CLI, CLI overrides. + effective = dict(default_settings) + effective.update(cli_settings) + + # Apply settings like: + # set MaxNetworks + for key, val in effective.items(): + send(sock, f"PRIVMSG *controlpanel :set {key} {user} {val}") + + print(f"Maken znc user {user}") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/zncdelete.py b/zncdelete.py new file mode 100755 index 0000000..15d1917 --- /dev/null +++ b/zncdelete.py @@ -0,0 +1,55 @@ +#!/usr/bin/python3.8 +# Script created/contributed by ~jmjl + +import socket +import ssl +import json +import sys + + +def loadconf(cfgfile): + with open(cfgfile, 'r') as f: + return json.load(f) + + +def send(msg): + s.send(f"{msg}\n".encode('utf-8')) + + +cfg = loadconf("/root/.znc-conf/znc-config.json") + +readbuffer = "" +s = socket.socket() +if cfg.get('tls') == 'yes': + ctx = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH) + s = ctx.wrap_socket(s) + +s.connect((cfg['srv'], int(cfg['port']))) +send("NICK bot") +send("USER bot 0 * :A bot to remove users") + +if len(sys.argv) != 2: + print("usage: zncdelete.py ") + sys.exit(1) + +user = sys.argv[1] + +while True: + readbuffer += s.recv(2048).decode('utf-8', errors='ignore') + temp = str.split(readbuffer, "\n") + readbuffer = temp.pop() + + for line in temp: + line = line.rstrip("\r") + parts = line.split() + + if len(parts) < 2: + continue + + if parts[1] == '464': + send(f"PASS {cfg['user']}:{cfg['password']}") + + if parts[0][1:] == 'irc.znc.in' and parts[1] == '001': + send(f"PRIVMSG *controlpanel :DelUser {user}") + print(f"Removed znc user {user}") + sys.exit(0)