diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7b3c49a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +resources/config.php +resources/libraries/composer/ +resources/custom_user_mappings/*.csv +bin/*.service + +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md index 561731e5..8f45352a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,30 @@ -# unity-web-portal -Web portal for the Unity Cluster +# Unity Cluster Website # + +### Installation ### +1. Prerequisites + 1. OpenLDAP Server with Admin Credentials, correct schema + 1. SQL Server, correct schema + 1. Slurm commands accessible on this host, `www-data` (or whatever the web server user is) should be an operator in `sacctmgr` + 1. SMTP Server + 1. Some HTTP Authentication mechanism (such as Shibboleth SP) +1. Install required PHP Libraries: + 1. Install composer `apt install composer` + 1. Create directory and navigate to `resources/libraries/composer` + 1. Install phpmailer: `composer require phpmailer/phpmailer` + 1. Install phpseclib: `composer require phpseclib/phpseclib` + 1. Install php-ldap `apt install php-ldap` +1. Setup config File `resources/config.php`, use the `resoures/config.php.example` as a reference +1. Apache Configs + + + +#### Directory Structure #### +* `/webroot` - Public root of the website (http document root) +* `/resources` - Private directory containing php files not necessary to be public. + +The unity/webroot directory should be the **only** publicly accessible location (DocumentRoot in htdocs). The resources directory contains many php scripts that are referenced absolutely in the config. + +#### Server Setup #### +This website has a public and private interface. The private interface is authenticated using a shibboleth SP. The following files/directories must be behind a shibboleth SP (configured through apache). +* `/panel` +* `/admin` for admins only \ No newline at end of file diff --git a/bin/skel/home/.bash_logout b/bin/skel/home/.bash_logout new file mode 100644 index 00000000..a465a9d0 --- /dev/null +++ b/bin/skel/home/.bash_logout @@ -0,0 +1,7 @@ +# ~/.bash_logout: executed by bash(1) when login shell exits. + +# when leaving the console clear the screen to increase privacy + +if [ "$SHLVL" = 1 ]; then + [ -x /usr/bin/clear_console ] && /usr/bin/clear_console -q +fi \ No newline at end of file diff --git a/bin/skel/home/.bashrc b/bin/skel/home/.bashrc new file mode 100644 index 00000000..27a2f56e --- /dev/null +++ b/bin/skel/home/.bashrc @@ -0,0 +1,117 @@ +# ~/.bashrc: executed by bash(1) for non-login shells. +# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc) +# for examples + +# If not running interactively, don't do anything +case $- in + *i*) ;; + *) return;; +esac + +# don't put duplicate lines or lines starting with space in the history. +# See bash(1) for more options +HISTCONTROL=ignoreboth + +# append to the history file, don't overwrite it +shopt -s histappend + +# for setting history length see HISTSIZE and HISTFILESIZE in bash(1) +HISTSIZE=1000 +HISTFILESIZE=2000 + +# check the window size after each command and, if necessary, +# update the values of LINES and COLUMNS. +shopt -s checkwinsize + +# If set, the pattern "**" used in a pathname expansion context will +# match all files and zero or more directories and subdirectories. +#shopt -s globstar + +# make less more friendly for non-text input files, see lesspipe(1) +[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)" + +# set variable identifying the chroot you work in (used in the prompt below) +if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then + debian_chroot=$(cat /etc/debian_chroot) +fi + +# set a fancy prompt (non-color, unless we know we "want" color) +case "$TERM" in + xterm-color|*-256color) color_prompt=yes;; +esac + +# uncomment for a colored prompt, if the terminal has the capability; turned +# off by default to not distract the user: the focus in a terminal window +# should be on the output of commands, not on the prompt +#force_color_prompt=yes + +if [ -n "$force_color_prompt" ]; then + if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then + # We have color support; assume it's compliant with Ecma-48 + # (ISO/IEC-6429). (Lack of such support is extremely rare, and such + # a case would tend to support setf rather than setaf.) + color_prompt=yes + else + color_prompt= + fi +fi + +if [ "$color_prompt" = yes ]; then + PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ ' +else + PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ ' +fi +unset color_prompt force_color_prompt + +# If this is an xterm set the title to user@host:dir +case "$TERM" in +xterm*|rxvt*) + PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1" + ;; +*) + ;; +esac + +# enable color support of ls and also add handy aliases +if [ -x /usr/bin/dircolors ]; then + test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)" + alias ls='ls --color=auto' + #alias dir='dir --color=auto' + #alias vdir='vdir --color=auto' + + alias grep='grep --color=auto' + alias fgrep='fgrep --color=auto' + alias egrep='egrep --color=auto' +fi + +# colored GCC warnings and errors +#export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01' + +# some more ls aliases +alias ll='ls -alF' +alias la='ls -A' +alias l='ls -CF' + +# Add an "alert" alias for long running commands. Use like so: +# sleep 10; alert +alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"' + +# Alias definitions. +# You may want to put all your additions into a separate file like +# ~/.bash_aliases, instead of adding them here directly. +# See /usr/share/doc/bash-doc/examples in the bash-doc package. + +if [ -f ~/.bash_aliases ]; then + . ~/.bash_aliases +fi + +# enable programmable completion features (you don't need to enable +# this, if it's already enabled in /etc/bash.bashrc and /etc/profile +# sources /etc/bash.bashrc). +if ! shopt -oq posix; then + if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion + elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi +fi \ No newline at end of file diff --git a/bin/skel/home/.profile b/bin/skel/home/.profile new file mode 100644 index 00000000..456110de --- /dev/null +++ b/bin/skel/home/.profile @@ -0,0 +1,27 @@ +# ~/.profile: executed by the command interpreter for login shells. +# This file is not read by bash(1), if ~/.bash_profile or ~/.bash_login +# exists. +# see /usr/share/doc/bash/examples/startup-files for examples. +# the files are located in the bash-doc package. + +# the default umask is set in /etc/profile; for setting the umask +# for ssh logins, install and configure the libpam-umask package. +#umask 022 + +# if running bash +if [ -n "$BASH_VERSION" ]; then + # include .bashrc if it exists + if [ -f "$HOME/.bashrc" ]; then + . "$HOME/.bashrc" + fi +fi + +# set PATH so it includes user's private bin if it exists +if [ -d "$HOME/bin" ] ; then + PATH="$HOME/bin:$PATH" +fi + +# set PATH so it includes user's private bin if it exists +if [ -d "$HOME/.local/bin" ] ; then + PATH="$HOME/.local/bin:$PATH" +fi \ No newline at end of file diff --git a/bin/skel/home/README b/bin/skel/home/README new file mode 100644 index 00000000..1e4380ab --- /dev/null +++ b/bin/skel/home/README @@ -0,0 +1,5 @@ +Hello Unity User! + +Welcome to the Unity cluster. If you're looking for documentation you can find that at https://unity.rc.umass.edu/docs. If you are looking for support please email hpc@umass.edu to reach our ticketing system, or fill out a form online at https://unity.rc.umass.edu/panel/contact.php. + +Enjoy! \ No newline at end of file diff --git a/bin/skel/scratch/README b/bin/skel/scratch/README new file mode 100644 index 00000000..565addf4 --- /dev/null +++ b/bin/skel/scratch/README @@ -0,0 +1 @@ +This is your scratch home folder. FOLDERS AND FILES IN THIS DIRECTORY WILL AUTO DELETE AFTER 90 DAYS OF INACTIVITY. This folder is a much higher performance location. Cluster jobs should be run off of this location. \ No newline at end of file diff --git a/bin/unity_fs.py b/bin/unity_fs.py new file mode 100644 index 00000000..a2421105 --- /dev/null +++ b/bin/unity_fs.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 + +import socket +import xml.etree.ElementTree as ET +import shutil +import os +import pwd +import grp +from _thread import * +import requests +from requests.auth import HTTPBasicAuth +import urllib3 +import time +import select + + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +dirname = os.path.dirname(__file__) + +HOST = '127.0.0.1' # Standard loopback interface address (localhost) +PORT = 2010 # Port to listen on (non-privileged ports are > 1023) + +NAS1_URL = "https://nas1.maas/api/v2.0" +NAS1_API_KEY = "1-IYQsR0H2riooJKKy8c1kvjhUlBzkejeqoRUwP4PJiKwvtrDEmTZMwmQka9DEOZaC" +NAS1_HOME_DATASET = "nas1-pool/home" +NAS1_PROJECT_DATASET = "nas1-pool/project" +NAS1_HEADERS = { 'Authorization': 'Bearer ' + NAS1_API_KEY } + +TIME_WAIT = 0.01 +TIMEOUT = 5 # timeout in seconds + +def threaded_client(conn): + NO_MESSAGE = 0 + + while True: + if (NO_MESSAGE * TIME_WAIT > TIMEOUT): + # timeout + break + + try: + data = conn.recv(1024).decode('utf-8') + + try: + tree = ET.ElementTree(ET.fromstring(data)) + root = tree.getroot() + NO_MESSAGE = 0 + except: + NO_MESSAGE += 1 + time.sleep(TIME_WAIT) + continue + + if root.tag != "request": + time.sleep(TIME_WAIT) + continue + + req_type = root.attrib["type"] + if req_type == "populate_home": + dest = "" + user = "" + group = "" + scratch_loc = "" + + for child in root: + if child.tag == "location": + dest = child.text + elif child.tag == "uid": + user = child.text + elif child.tag == "gid": + group = child.text + elif child.tag == "scratch": + scratch_loc = child.text + + if not user.isdigit(): + user = pwd.getpwnam(user).pw_uid + if not group.isdigit(): + group = grp.getgrnam(group).gr_gid + + source_home = os.path.join(dirname, 'skel/home') + for root, dirs, files in os.walk(source_home): + for file in files: + fullpath = os.path.join(root, file) + destpath = os.path.join(dest, file) + shutil.copy(fullpath, destpath) + os.chown(destpath, int(user), int(group)) + + # create symlink to scratch + if scratch_loc != "": + source_path = os.path.join(dest, "scratch") + os.symlink(scratch_loc, source_path) + os.chown(source_path, int(user), int(group), follow_symlinks = False) + + conn.send("success".encode()) + + elif req_type == "populate_scratch": + dest = "" + user = "" + group = "" + + for child in root: + if child.tag == "location": + dest = child.text + elif child.tag == "uid": + user = child.text + elif child.tag == "gid": + group = child.text + + if not user.isdigit(): + user = pwd.getpwnam(user).pw_uid + if not group.isdigit(): + group = grp.getgrnam(group).gr_gid + + source_scratch = os.path.join(dirname, 'skel/scratch') + for root, dirs, files in os.walk(source_scratch): + for file in files: + fullpath = os.path.join(root, file) + destpath = os.path.join(dest, file) + shutil.copy(fullpath, destpath) + os.chown(destpath, int(user), int(group)) + + conn.send("success".encode()) + + elif req_type == "create_home": + user = "" + group = "" + quota = "" + + for child in root: + if child.tag == "uid": + user = child.text + elif child.tag == "gid": + group = child.text + elif child.tag == "quota": + quota = child.text + + full_url = NAS1_URL + "/pool/dataset" + dataset_path = NAS1_HOME_DATASET + "/" + user + data = { + "name": dataset_path, + "quota": quota + } + + # post request + result = requests.post(full_url, json = data, headers = NAS1_HEADERS, verify = False) + code = result.status_code + if code != 200 and code != 422: + error = "Error creating dataset" + conn.send(error.encode()) + time.sleep(TIME_WAIT) + continue + + # set perms + data = { + "user": user, + "group": group, + "acl": [ + { + "tag": "owner@", + "id": None, + "type": "ALLOW", + "perms": { + "READ_DATA": True, + "WRITE_DATA": True, + "APPEND_DATA": True, + "READ_NAMED_ATTRS": True, + "WRITE_NAMED_ATTRS": True, + "DELETE_CHILD": True, + "READ_ATTRIBUTES": True, + "WRITE_ATTRIBUTES": True, + "DELETE": True, + "READ_ACL": True, + "WRITE_ACL": True, + "WRITE_OWNER": True, + "SYNCHRONIZE": True + }, + "flags": { + "BASIC": "INHERIT" + } + }, + { + "tag": "owner@", + "id": None, + "type": "ALLOW", + "perms": { + "EXECUTE": True + }, + "flags": {"DIRECTORY_INHERIT": True} + }, + { + "tag": "group@", + "id": None, + "type": "ALLOW", + "perms": { + "READ_DATA": True, + "READ_NAMED_ATTRS": True, + "READ_ATTRIBUTES": True, + "READ_ACL": True, + "SYNCHRONIZE": True + }, + "flags": {"BASIC": "INHERIT"} + }, + { + "tag": "group@", + "id": None, + "type": "ALLOW", + "perms": { + "EXECUTE": True + }, + "flags": {"DIRECTORY_INHERIT": True} + }, + { + "tag": "everyone@", + "id": None, + "type": "ALLOW", + "perms": { + "READ_NAMED_ATTRS": True, + "READ_ATTRIBUTES": True, + "READ_ACL": True + }, + "flags": {"BASIC": "INHERIT"} + } + ], + "options": { + "stripacl": False, + "recursive": True, + "traverse": True + } + } + + full_url = NAS1_URL + "/pool/dataset/id/" + dataset_path.replace('/', '%2F') + "/permission" + result = requests.post(full_url, json = data, headers = NAS1_HEADERS, verify = False) + code = result.status_code + if code != 200 and code != 422: + error = "Error setting dataset permissions" + conn.send(error.encode()) + time.sleep(TIME_WAIT) + continue + + full_url = NAS1_URL + "/sharing/nfs" + mnt_path = "/mnt/" + dataset_path + data = { + "paths": [mnt_path], + "comment": "", + "networks": [ + "10.100.0.0/16", + "10.10.0.0/16" + ], + "hosts": [], + "alldirs": True, + "ro": False, + "quiet": False, + "maproot_user": "root", + "maproot_group": "wheel", + "mapall_user": "", + "mapall_group": "", + "security": [], + "enabled": True + } + + result = requests.post(full_url, json = data, headers = NAS1_HEADERS, verify = False) + code = result.status_code + if code != 200 and code != 422: + error = "Error creating NFS export" + conn.send(error.encode()) + time.sleep(TIME_WAIT) + continue + + conn.send("Success".encode()) + + elif req_type == "create_scratch": + user = "" + group = "" + + for child in root: + if child.tag == "uid": + user = child.text + elif child.tag == "gid": + group = child.text + + scratch_path = "/scratch/" + user + + if not user.isdigit(): + user = pwd.getpwnam(user).pw_uid + if not group.isdigit(): + group = grp.getgrnam(group).gr_gid + + os.mkdir(scratch_path) + + os.chown(scratch_path, int(user), int(group)) + + conn.send("Success".encode()) + + except Exception as e: + print(e) + xml_error = "" + str(e) + "" + conn.send(xml_error.encode()) + + time.sleep(TIME_WAIT) + + conn.close() + +s = socket.socket() +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +try: + s.bind((HOST, PORT)) +except socket.error as e: + print(str(e)) + +s.listen(5) + +while True: + (conn, addr) = s.accept() + start_new_thread(threaded_client, (conn, )) + +s.close() \ No newline at end of file diff --git a/bin/unityfs.service.example b/bin/unityfs.service.example new file mode 100644 index 00000000..dfee09a5 --- /dev/null +++ b/bin/unityfs.service.example @@ -0,0 +1,11 @@ +[Unit] +Description=Unity Filesystem Daemon +After=network.target + +[Service] +WorkingDirectory= +ExecStart=python3 unity_fs.py +Restart=always + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/changelog b/changelog new file mode 100644 index 00000000..41b7bb57 --- /dev/null +++ b/changelog @@ -0,0 +1,16 @@ +#### 0.5.0-BETA #### +* [Feature] Added notices for inactive account +* [Locale] New XML based locale for easier dynamic lang switching +* [Feature] Added VAST storage and Truenas Core storage integration for automatic storage creation +* [Feature] Added GitHub SSH key import +* [Performance] Nested groups are loaded via AJAX instead of directly to avoid long loading times +* [Feature] Added support for custom user id mappings +* [Style] General style changes +* [Feature] Added unityfs service, which is a socket service responsible for provisioning storage on Unity + +#### 0.4.0HF1-BETA #### +* [Bug] Fixed issue where some users account wasn't activating + +#### 0.4.0-BETA #### +* [Feature] Added Multiple PI Support +* [Backend] Users and PI Groups are treated as different entities \ No newline at end of file diff --git a/etc/ldap/schema/unity_groups.schema b/etc/ldap/schema/unity_groups.schema new file mode 100644 index 00000000..d8308757 --- /dev/null +++ b/etc/ldap/schema/unity_groups.schema @@ -0,0 +1,27 @@ +attributetype ( 1.3.6.1.4.1.8473.2.1.100000 + NAME 'groupType' + DESC 'Group type' + EQUALITY caseExactMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32} + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.8473.2.1.100001 + NAME 'groupOwner' + DESC 'Group owner UID' + EQUALITY caseExactMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32} + SINGLE-VALUE ) + +attributetype ( 1.3.6.1.4.1.8473.2.1.100002 + NAME 'groupRequests' + DESC 'UIDs that have requested to join this group' + EQUALITY caseExactMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32} + MULTI-VALUE ) + +objectclass ( 1.3.6.1.4.1.8473.2.2.100000 + NAME 'unityGroup' + DESC 'Unity Group Class' + SUP ( top $ posixGroup ) + STRUCTURAL + MUST ( cn $ groupType $ groupOwner ) ) \ No newline at end of file diff --git a/resources/autoload.php b/resources/autoload.php new file mode 100644 index 00000000..3233921b --- /dev/null +++ b/resources/autoload.php @@ -0,0 +1,55 @@ +here"); +} + +require_once config::PATHS["templates"] . "/globals.php"; + +require_once config::PATHS["libraries"] . "/slurm.php"; +require_once config::PATHS["libraries"] . "/unityfs.php"; +require_once config::PATHS["libraries"] . "/unity-ldap.php"; +require_once config::PATHS["libraries"] . "/unity-user.php"; +require_once config::PATHS["libraries"] . "/unity-account.php"; +require_once config::PATHS["libraries"] . "/unity-sql.php"; +require_once config::PATHS["libraries"] . "/template_mailer.php"; + +require_once config::PATHS["libraries"] . "/unity-service.php"; + +$SERVICE = new serviceStack(); +$SERVICE->add_ldap(config::LDAP); +$SERVICE->add_sql(config::SQL); +$SERVICE->add_mail(config::MAIL); +$SERVICE->add_sacctmgr(config::SLURM); +$SERVICE->add_unityfs(config::UNITYFS); + +if (isset($_SERVER["REMOTE_USER"])) { // Check if SHIB is enabled on this page + // Set Shibboleth Session Vars - Vars stored in session to be accessible outside shib-controlled areas of the sites (ie contact page) + $SHIB = array( + "netid" => EPPN_to_uid($_SERVER["REMOTE_USER"]), + "firstname" => $_SERVER["givenName"], + "lastname" => $_SERVER["sn"], + "name" => $_SERVER["givenName"] . " " . $_SERVER["sn"], + "mail" => isset($_SERVER["mail"]) ? $_SERVER["mail"] : $_SERVER["eppn"] // Fallback to EPPN if mail is not set + ); + $_SESSION["SHIB"] = $SHIB; // Set the session var for non-authenticated pages + + $USER = new unityUser($SHIB["netid"], $SERVICE); + $_SESSION["user_exists"] = $USER->exists(); + $_SESSION["is_pi"] = $USER->isPI(); + $_SESSION["is_admin"] = $USER->isAdmin(); +} + +// Load Locale +require_once config::PATHS["locale"] . "/" . config::LOCALE . ".php"; // Loads the locale class \ No newline at end of file diff --git a/resources/config.php.example b/resources/config.php.example new file mode 100644 index 00000000..9b53db53 --- /dev/null +++ b/resources/config.php.example @@ -0,0 +1,85 @@ + "https://github.com/UMass-RC/unity-web-portal", + "version" => "0.5.0-BETA" + ); + + public const PATHS = array( + "templates" => ROOT . "/resources/templates", + "libraries" => ROOT . "/resources/libraries", + "locale" => ROOT . "/resources/locale", + "skel" => ROOT . "/resources/etc/skel", + "custom_user" => ROOT . "/resources/custom_user_mappings" + ); + + public const LDAP = array( + "bind_dn" => "", + "bind_pass" => "", + "uri" => "" + ); + + public const SQL = array( + "host" => "", + "user" => "", + "pass" => "", + "db" => "" + ); + + public const SLURM = array( + "cluster" => "" + ); + + public const MAIL = array( + "host" => "", + "smtp_user" => "", + "smtp_pass" => "", + "smtp_secure" => "", + "port" => "", + "template_path" => config::PATHS["templates"] . "/mail", + "addresses" => array( + "contact" => array("", ""), + "sender" => array("", "") + ) + ); + + public const UNITYFS = array( + "host" => "", + "port" => "" + ); + + public const SUPPORT = array( + "sendmail" => "" + ); + + public const LOCALE = "en"; + + public const CLUSTER = array( + "name" => "", + "org" => "", + "desc" => "" + ); +} diff --git a/resources/custom_user_mappings/README b/resources/custom_user_mappings/README new file mode 100644 index 00000000..7d717a4c --- /dev/null +++ b/resources/custom_user_mappings/README @@ -0,0 +1,5 @@ +Put any csv in this folder in the format: + +, + +and the website will auto assign that id (assuming it is available) when that user applies for an account. \ No newline at end of file diff --git a/resources/libraries/ldap.php b/resources/libraries/ldap.php new file mode 100644 index 00000000..3deacb87 --- /dev/null +++ b/resources/libraries/ldap.php @@ -0,0 +1,438 @@ + + * @version 1.0.0 + * @since 7.2.0 + */ +class ldapEntry { + private $conn; // LDAP connection link + private $dn; // Distinguished Name of the Entry + + private $object; // Array containing the attributes of the entry as it looks on the server + private $mods; // Array containing modifications to $object array that have yet to be applied + + /** + * Constructor that creates an ldapEntry object + * + * @param link_identifier $conn LDAP connection link from ldap_connect, ldap_bind must have already been used + * @param string $dn Distinguished Name of the requested entry + */ + public function __construct($conn, $dn) { + $this->conn = $conn; + $this->dn = $dn; + $this->pullObject(); + } + + /** + * Pulls an entry from the ldap connection, and sets $object If entry does not exist, $object = null. + */ + private function pullObject() { + $search = @ldap_get_entries($this->conn, ldap_read($this->conn, $this->dn, "(objectclass=*)")); + ldapConn::stripCount($search); + + if (isset($search)) { + // Object Exists + if (count($search) > 1) { // 1 For LDAP count element, and 1 for actual object + // Duplicate Objects Found + die("FATAL: Call to ldapObject with non-unique DN."); + } else { + $this->object = $search[0]; + } + } + } + + /** + * Gets the Distinguished Name (DN) of the Entry + * + * @return string DN of the entry + */ + public function getDN() { + return $this->dn; + } + + /** + * Gets the Relative Distinguished Name (RDN) of the Entry + * + * @return string RDN of the entry + */ + public function getRDN() { + return substr($this->dn, 0, strpos($this->dn, ',')); + } + + /** + * Checks whether entry exists on the LDAP server, modifications that haven't been applied don't count + * + * @return bool True if entry exists, False if it does not exist + */ + public function exists() { + return !is_null($this->object); + } + + /** + * Writes changes set in $mods array to the LDAP entry on the server. + * + * @return bool True on success, False on failure + */ + public function write() { + if ($this->object == NULL) { + $success = ldap_add($this->conn, $this->dn, $this->mods); // Create a New Entry + } else { + if ($this->mods == NULL) { + throw new Exception("No modifications were made"); + } else { + $success = ldap_mod_replace($this->conn, $this->dn, $this->mods); // Modify Existing Entry + } + } + + if ($success) { + $this->pullObject(); // Refresh $object array + $this->mods = NULL; // Reset Modifications Array to Null + } + return $success; + } + + /** + * Deletes the entry (no need to call write()) + * + * @return bool True on success, False on failure + */ + public function delete() { + if ($this->object == NULL) { + return true; + } else { + if(ldap_delete($this->conn, $this->dn)) { + $this->pullObject(); + $this->mods = NULL; + return true; + } else { + return false; + } + } + } + + /** + * Moves the entry to a new location + * + * @param string $destination Destination CN to move this entry + * @return mixed ldapEntry of the new entry if successful, false on failure + */ + public function move($destination) { + $newRDN = substr($destination, 0, strpos($destination, ',')); + $newParent = substr($destination, strpos($destination, ',') + 1); + if (ldap_rename($this->conn, $this->dn, $newRDN, $newParent, true)) { + $this->pullObject(); // Refresh the existing entry + return new ldapEntry($this->conn, $destination); + } else { + return false; + } + } + + /** + * Gets the immediate parent of the entry + * + * @return ldapEntry The parent of the current Entry + */ + public function getParent() { + return new ldapEntry($this->conn, substr($this->dn, strpos($this->dn, ',') + 1)); //TODO edge case for parent being non-existent (part of base dn) + } + + /** + * Gets an array of children of the entry + * + * @param boolean $recursive (optional) If true, recursive search. Default is false. + * @param string $filter (optional) Filter matching LDAP search filter syntax + * @return array Array of children entries + */ + public function getChildrenArray($recursive = false, $filter = "(objectclass=*)") { + if ($recursive) { + $search = ldap_search($this->conn, $this->dn, $filter); + } else { + $search = ldap_list($this->conn, $this->dn, $filter); + } + + $search_entries = @ldap_get_entries($this->conn, $search); + ldapConn::stripCount($search_entries); + + if (count($search_entries) > 0 && $search_entries[0]["dn"] == $this->getDN()) { + array_shift($search_entries); + } + + return $search_entries; + } + + /** + * Gets an array of the children of the entry saved as ldapEntry class + * + * @param bool $recursive (optional) If true, recursive search. Default is false. + * @param string $filter (optional) Filter matching LDAP search filter syntax + * @return array Array of children ldapEntry objects + */ + public function getChildren($recursive = false, $filter = "(objectclass=*)") { + $children_array = $this->getChildrenArray($recursive, $filter); + + $output = array(); + foreach ($children_array as $child) { + array_push($output, new ldapEntry($this->conn, $child["dn"])); + } + + return $output; + } + + /** + * Gets a single child using RDN + * + * @param string $rdn RDN of requested child + * @return ldapEntry object of the child + */ + public function getChild($rdn) { + return new ldapEntry($this->conn, $rdn . "," . $this->dn); + } + + /** + * Checks if entry has any children + * + * @return boolean True if yes, False if no + */ + public function hasChildren() { + return count($this->getChildrenArray()) > 0; + } + + /** + * Gets the number of children of the entry + * + * @param boolean $recursive (optional) If true, recursive search. Default is false. + * @return int Number of children of entry + */ + public function numChildren($recursive = false) { + return count($this->getChildrenArray($recursive)); + } + + /** + * Sets the value of a single attribute in the LDAP object (This will overwrite any existing values in the attribute) + * + * @param string $attr Attribute Key Name to modify + * @param mixed $value array or string value to set the attribute value to + */ + public function setAttribute($attr, $value) { + if (is_array($value)) { + $this->mods[$attr] = $value; + } else { + $this->mods[$attr] = array($value); + } + } + + /** + * Appends values to a given attribute, preserving initial values in the attribute + * + * @param string $attr Attribute Key Name to modify + * @param mixed $value array or string value to append attribute + */ + public function appendAttribute($attr, $value) { + $objArr = array(); + if (isset($this->object[$attr])) { + $objArr = $this->object[$attr]; + } + + $modArr = array(); + if (isset($this->mods[$attr])) { + $modArr = $this->mods[$attr]; + } + + if (is_array($value)) { + $this->mods[$attr] = array_merge($objArr, $modArr, $value); + } else { + $this->mods[$attr] = array_merge($objArr, $modArr, (array) $value); + } + } + + /** + * Sets and overwrites attributes based on a single array. + * + * @param array $arr Array of keys and attributes. Key values must be attribute key + */ + public function setAttributes($arr) { + $this->mods = $arr; + } + + /** + * Appends attributes based on a single array + * + * @param array $arr Array of keys and attributes. Key values must be attribute key + */ + public function appendAttributes($arr) { + foreach($arr as $attr) { + $this->appendAttribute(key($attr), $attr); + } + } + + /** + * Removes a attribute + * + * @param string $attr Key of attribute to be removed + */ + public function removeAttribute($attr, $item = NULL) { + $this->mods[$attr] = array(); + } + + /** + * Removes values of an attribute + * + * @param string $attr Attribute to modify + * @param string $value Value to erase from attribute + */ + public function removeAttributeEntryByValue($attr, $value) { + $arr = $this->object[$attr]; + for ($i = 0; $i < count($arr); $i++) { + if ($arr[$i] == $value) { + unset($arr[$i]); + } + } + $this->mods[$attr] = array_values($arr); + } + + /** + * Returns a given attribute of the object + * + * @param string $attr Attribute key value to return + * @return array value of requested attribute. Note: lots of attributes are arrays by default, so you have to use index 0 of the return value to get a single value + */ + public function getAttribute($attr) { + if (isset($this->object[$attr])) { + return $this->object[$attr]; + } else { + return NULL; + } + } + + /** + * Returns the entire objects attributes + * + * @return array Array where keys are attributes + */ + public function getAttributes() { + return $this->object; + } + + /** + * Checks if entry has an attribute + * + * @param string $attr Attribute to check + * @return bool true if attribute exists in entry, false otherwise + */ + public function hasAttribute($attr) { + if ($this->exists()) { + return array_key_exists($attr, $this->object); + } else { + return false; + } + } + + /** + * Checks if an attribute value exists within an attribute + * + * @param string $attr Attribute to check + * @param string $value Value to check + * @return bool true if value exists in attribute, false otherwise + */ + public function attributeValueExists($attr, $value) { + return in_array($value, $this->getAttribute($attr)); + } + + /** + * Check if there are pending changes + * + * @return bool true is there are pending changes, false otherwise + */ + public function pendingChanges() { + return !is_null($this->mods); + } +} + +/** + * Class that represents a connection to an LDAP server + * + * Originally written for UMASS Amherst Research Computing + * + * @author Hakan Saplakoglu + * @version 1.0.0 + * @since 7.2.0 + */ +class ldapConn { + protected $conn; // LDAP link + + /** + * Constructor, starts an ldap connection and binds to a DN + * + * @param string $host Host ldap address of server + * @param string $bind_dn Admin bind dn + * @param string $bind_pass Admin bind pass + */ + public function __construct($host, $bind_dn, $bind_pass) { + $this->conn = ldap_connect($host); + + ldap_set_option($this->conn, LDAP_OPT_PROTOCOL_VERSION, 3); + ldap_bind($this->conn, $bind_dn, $bind_pass); + } + + /** + * Get the connection instance of the LDAP link + * + * @return link_identifier LDAP connection link + */ + public function getConn() { + return $this->conn; + } + + /** + * Runs a search on the LDAP server and returns entries + * + * @param string $filter LDAP_search filter + * @param string $base Search base + * @return array Array of ldapEntry objects + */ + public function search($filter, $base, $recursive = true) { + if ($recursive) { + $search = ldap_search($this->conn, $base, $filter); + } else { + $search = ldap_list($this->conn, $base, $filter); + } + + $search_entries = @ldap_get_entries($this->conn, $search); + self::stripCount($search_entries); + + $output = array(); + for($i = 0; isset($search_entries) && $i < count($search_entries); $i++) { + array_push($output, new ldapEntry($this->conn, $search_entries[$i]["dn"])); + } + + return $output; + } + + /** + * Gets a single entry from the LDAP server + * + * @param string $dn Distinguished name (DN) of requested entry + * @return ldapEntry requested entry object + */ + public function getEntry($dn) { + return new ldapEntry($this->conn, $dn); + } + + /** + * Removes the very annoying "count" attribute that comes out of all ldap search queries (why does that exist? Every language I know can figure out the count itself) + * + * @param array $arr Array passed by reference to modify + */ + public static function stripCount(&$arr) { + if(is_array($arr)) { + unset($arr['count']); + array_walk($arr, "ldapConn::stripCount"); + } + } +} diff --git a/resources/libraries/slurm.php b/resources/libraries/slurm.php new file mode 100644 index 00000000..ed4ee9c6 --- /dev/null +++ b/resources/libraries/slurm.php @@ -0,0 +1,151 @@ +cluster = $clustername; + } + + private static function cmd($cmd) + { + exec($cmd, $output, $return); + + if ($return != 0) { + throw new Exception("$cmd returned error code $return with output " . var_dump($output)); + } + + return $output; + } + + public function addAccount($name) + { + if (!$this->accountExists($name)) { + self::cmd(self::CMD_PREFIX . "add account $name Cluster=$this->cluster"); + } + } + + public function deleteAccount($name) + { + if ($this->accountExists($name)) { + self::cmd(self::CMD_PREFIX . "delete account $name"); + } + } + + public function addUser($name, $account) + { + if (!$this->userExists($name, $account)) { + self::cmd(self::CMD_PREFIX . "add user $name Account=$account"); + } + } + + public function deleteUser($name, $account) + { + if ($this->userExists($name, $account)) { + self::cmd(self::CMD_PREFIX . "delete user $name where Account=$account"); + } + } + + public function accountExists($name) + { + return count($this->readAssocDB(array("Account=$name"))) > 0; + } + + public function userExists($name, $account = NULL) + { + if (is_null($account)) { + return count($this->readAssocDB(array("User=$name"))) > 0; + } else { + return count($this->readAssocDB(array("User=$name","Account=$account"))) > 0; + } + } + + public function getAccountsFromUser($user) { + $accounts = $this->readAssocDB(array("User=$user")); + + $out = array(); + foreach($accounts as $account) { + if ($account[1] != "" && $account[1] != "root") { + array_push($out, $account[1]); // index 2 is UID + } + } + + return $out; + } + + public function getUsersFromAccount($account) { + $users = $this->readAssocDB(array("Account=$account")); + + $out = array(); + foreach($users as $user) { + if ($user[2] != "" && $user[2] != "root") { + array_push($out, $user[2]); // index 2 is UID + } + } + + return $out; + } + + public function getAccounts() { + $accounts_raw = $this->readAccDB(); + + $out = array(); + foreach ($accounts_raw as $account_raw) { + $pi_netid = $account_raw[0]; + if ($pi_netid != "root") { // disregard root account + array_push($out, $pi_netid); + } + } + + return $out; + } + + /** + * Updates the local var with cluster associations + */ + private function readAssocDB($filters = array()) { + $query = self::CMD_PREFIX . "-P show associations"; // -P is parsable output + + foreach ($filters as $filter) { + $query .= " where $filter"; + } + + $associations = self::cmd($query); + array_shift($associations); // Remove the key output + + $out = array(); + foreach($associations as $assoc) { + $exploded = explode("|", $assoc); + array_push($out, $exploded); + } + + return $out; + } + + private function readAccDB($filters = array()) { + $query = self::CMD_PREFIX . "-P show accounts"; // -P is parsable output + + foreach ($filters as $filter) { + $query .= " where $filter"; + } + + $associations = self::cmd($query); + array_shift($associations); // Remove the key output + + $out = array(); + foreach($associations as $assoc) { + $exploded = explode("|", $assoc); + array_push($out, $exploded); + } + + return $out; + } +} diff --git a/resources/libraries/template_mailer.php b/resources/libraries/template_mailer.php new file mode 100644 index 00000000..366a82e4 --- /dev/null +++ b/resources/libraries/template_mailer.php @@ -0,0 +1,36 @@ +template_dir = $template_dir; + } + + public function send($template = null, $data = null) { + if (isset($template)) { + ob_start(); + require_once $this->template_dir . "/" . $template . ".php"; + $mes_html = ob_get_clean(); + $this->msgHTML($mes_html); + } + + if (parent::send()) { + // Clear addresses + $this->clearAllRecipients(); + return true; + } else { + return false; + } + } +} + +?> diff --git a/resources/libraries/unity-account.php b/resources/libraries/unity-account.php new file mode 100644 index 00000000..695672c2 --- /dev/null +++ b/resources/libraries/unity-account.php @@ -0,0 +1,265 @@ + + * @param unityLDAP $unityLDAP LDAP Connection + * @param unitySQL $unitySQL SQL Connection + * @param slurm $sacctmgr Slurm Connection + */ + public function __construct($pi_uid, $service_stack) + { + $this->pi_uid = $pi_uid; + + if (is_null($service_stack->ldap())) { + throw new Exception("LDAP is required for the unityUser class"); + } + + if (is_null($service_stack->sql())) { + throw new Exception("SQL is required for the unityUser class"); + } + + if (is_null($service_stack->sacctmgr())) { + throw new Exception("sacctmgr is required for the unityUser class"); + } + + if (is_null($service_stack->unityfs())) { + throw new Exception("unityfs is required for the unityUser class"); + } + + $this->service_stack = $service_stack; + } + + /** + * Returns this group's PI UID + * + * @return string PI UID of the group + */ + public function getPIUID() { + return $this->pi_uid; + } + + /** + * Checks if the current PI is an approved and existent group + * + * @return bool true if yes, false if no + */ + public function exists() + { + return $this->service_stack->sacctmgr()->accountExists($this->pi_uid); + } + + public function createGroup() + { + // make this user a PI + $owner = $this->getOwner(); + + // (1) Create LDAP PI group + $ldapPiGroupEntry = $this->getLDAPPiGroup(); + + if (!$ldapPiGroupEntry->exists()) { + $nextGID = $this->service_stack->ldap()->getNextPiGID(); + + $ldapPiGroupEntry->setAttribute("objectclass", unityLDAP::POSIX_GROUP_CLASS); + $ldapPiGroupEntry->setAttribute("gidnumber", strval($nextGID)); + $ldapPiGroupEntry->setAttribute("memberuid", array($owner->getUID())); // add current user as the first memberuid + + if (!$ldapPiGroupEntry->write()) { + throw new Exception("Failed to create POSIX group for " . $owner->getUID()); + } + } + + // (2) Create slurm account + $this->createSlurmAccount(); + $this->addAssociation(self::getUIDfromPIUID($this->pi_uid)); // add owner user + } + + public function removeGroup() { + $this->service_stack->sql()->removeRequests($this->pi_uid); // remove any lasting requests + + $users = $this->getGroupMembers(); + foreach ($users as $user) { + $this->removeUserFromGroup($user); + } + + // remove admin + $this->removeUserFromGroup($this->getOwner()); + + $ldapPiGroupEntry = $this->getLDAPPiGroup(); + if ($ldapPiGroupEntry->exists()) { + if (!$ldapPiGroupEntry->delete()) { + throw new Exception("Unable to delete PI ldap group"); + } + } + + $this->removeSlurmAccount(); + } + + public function getOwner() { + return new unityUser(self::getUIDfromPIUID($this->pi_uid), $this->service_stack); + } + + public function getLDAPPiGroup() + { + $group_entries = $this->service_stack->ldap()->pi_groupOU->getChildren(true, "(" . unityLDAP::RDN . "=" . $this->pi_uid . ")"); + + if (count($group_entries) > 0) { + return $group_entries[0]; + } else { + return new ldapEntry($this->service_stack->ldap()->getConn(), unityLDAP::RDN . "=$this->pi_uid," . unityLDAP::PI_GROUPS); + } + } + + public function addUserToGroup($new_user) + { + // Create Association + $this->addAssociation($new_user->getUID()); + + // Add to LDAP Group + $pi_group = $this->getLDAPPiGroup(); + $pi_group->appendAttribute("memberuid", $new_user->getUID()); + if (!$pi_group->write()) { + // failed to write + $this->removeAssociation($new_user->getUID()); + } + } + + public function removeUserFromGroup($old_user) + { + // Remove Association + $this->removeAssociation($old_user->getUID()); + + // Remove from LDAP Group + $pi_group = $this->getLDAPPiGroup(); + $pi_group->removeAttributeEntryByValue("memberuid", $old_user->getUID()); + if (!$pi_group->write()) { + // failed to write + $this->addAssociation($old_user->getUID()); + } + } + + // + // Slurm Related Functions + // + + /** + * Creates a group based off this user (this user is a PI) + */ + private function createSlurmAccount() + { + $this->service_stack->sacctmgr()->addAccount($this->pi_uid); + } + + private function removeSlurmAccount() + { + $this->service_stack->sacctmgr()->deleteAccount($this->pi_uid); + } + + private function addAssociation($uid) + { + if (!$this->service_stack->sacctmgr()->accountExists($this->pi_uid)) { + throw new Exception("Unable to create an association to a nonexist account $this->pi_uid"); + } + + // Add Slurm User + $this->service_stack->sacctmgr()->addUser($uid, $this->pi_uid); + } + + /** + * Undocumented function + * + * @param [type] $netid + * @return void + */ + private function removeAssociation($uid) + { + if (!$this->service_stack->sacctmgr()->userExists($uid, $this->pi_uid)) { + throw new Exception("Unable to remove association because an association doesn't exist"); + } + + $this->service_stack->sacctmgr()->deleteUser($uid, $this->pi_uid); + } + + + public function getAssociations() + { + return $this->service_stack->sacctmgr()->getUsersFromAccount($this->pi_uid); + } + + public function getGroupMembers() + { + $members = $this->getAssociations(); + + $out = array(); + $owner_uid = $this->getOwner()->getUID(); + foreach ($members as $member) { + if ($member != $owner_uid) { + array_push($out, new unityUser($member, $this->service_stack)); + } + } + + return $out; + } + + public static function getPIFromPIGroup($pi_netid) + { + if (substr($pi_netid, 0, strlen(self::PI_PREFIX)) == self::PI_PREFIX) { + return substr($pi_netid, strlen(self::PI_PREFIX)); + } else { + throw new Exception("PI netid doesn't have the correct prefix."); + } + } + + public function addRequest($uid) + { + $this->service_stack->sql()->addRequest($uid, $this->pi_uid); + } + + public function removeRequest($uid) + { + $this->service_stack->sql()->removeRequest($uid, $this->pi_uid); + } + + public function getRequests() + { + $requests = $this->service_stack->sql()->getRequests($this->pi_uid); + + $out = array(); + foreach ($requests as $request) { + array_push($out, new unityUser($request["uid"], $this->service_stack)); + } + + return $out; + } + + public static function getPIUIDfromUID($uid) + { + return self::PI_PREFIX . $uid; + } + + public static function getUIDfromPIUID($pi_uid) + { + if (substr($pi_uid, 0, strlen(self::PI_PREFIX)) == self::PI_PREFIX) { + return substr($pi_uid, strlen(self::PI_PREFIX)); + } else { + throw new Exception("PI netid doesn't have the correct prefix."); + } + } +} \ No newline at end of file diff --git a/resources/libraries/unity-ldap.php b/resources/libraries/unity-ldap.php new file mode 100644 index 00000000..0916a107 --- /dev/null +++ b/resources/libraries/unity-ldap.php @@ -0,0 +1,205 @@ +userOU = $this->getEntry(self::USERS); + $this->groupOU = $this->getEntry(self::GROUPS); + $this->pi_groupOU = $this->getEntry(self::PI_GROUPS); + $this->adminGroup = $this->getEntry(self::ADMIN_GROUP); + } + + public function getNextUID() + { + $users = $this->userOU->getChildrenArray(true); // ! Restore this instead of temporary + + usort($users, function ($a, $b) { + return $a["uidnumber"] <=> $b["uidnumber"]; + }); + + $id = self::ID_MAP[0]; + foreach ($users as $acc) { + if ($id == $acc["uidnumber"][0]) { + $id++; + if ($id > self::ID_MAP[1]) { + throw new Exception("UID Limits reached"); // all hell has broken if this executes + } + } else { + break; + } + } + + return $id; + } + + public function getNextGID() + { + $groups = $this->groupOU->getChildrenArray(true); + + usort($groups, function ($a, $b) { + return $a["gidnumber"] <=> $b["gidnumber"]; + }); + + $id = self::ID_MAP[0]; + foreach ($groups as $acc) { + if ($id == $acc["gidnumber"][0]) { + $id++; + if ($id > self::ID_MAP[1]) { + throw new Exception("GID Limits reached"); // all hell has broken if this executes + } + } else { + break; + } + } + + return $id; + } + + public function getNextPiGID() + { + $groups = $this->pi_groupOU->getChildrenArray(true); + + usort($groups, function ($a, $b) { + return $a["gidnumber"] <=> $b["gidnumber"]; + }); + + $id = self::PI_ID_MAP[0]; + foreach ($groups as $acc) { + if ($id == $acc["gidnumber"][0]) { + $id++; + if ($id > self::PI_ID_MAP[1]) { + throw new Exception("Storage GID Limits reached"); // all hell has broken if this executes + } + } else { + break; + } + } + + return $id; + } + + public function getAllRecipients() + { + $users = $this->userOU->getChildren(true); + + $out = array(); + foreach ($users as $user) { + array_push($out, $user->getAttribute("mail")[0]); + } + + return $out; + } + + private function UIDNumInUse($id) { + $users = $this->userOU->getChildrenArray(true); + foreach ($users as $user) { + if ($user["uidnumber"][0] == $id) { + return true; + } + } + + return false; + } + + private function GIDNumInUse($id) { + $users = $this->groupOU->getChildrenArray(true); + foreach ($users as $user) { + if ($user["gidnumber"][0] == $id) { + return true; + } + } + + return false; + } + + public function getUnassignedID($uid) + { + $netid = strtok($uid, "_"); // extract netid + // scrape all files in custom folder + $dir = new DirectoryIterator(config::PATHS["custom_user"]); + foreach ($dir as $fileinfo) { + if ($fileinfo->getExtension() == "csv") { + // found csv file + $handle = fopen($fileinfo->getPathname(), "r"); + while (($data = fgetcsv($handle, 1000, ",")) !== FALSE) { + $netid_match = $data[0]; + $uid_match = $data[1]; + + if ($uid == $netid_match || $netid == $netid_match) { + // found a match + if (!$this->UIDNumInUse($uid_match) && !$this->GIDNumInUse($uid_match)) { + return $uid_match; + } + } + } + } + } + + // didn't find anything from existing mappings, use next available + $next_uid = $this->getNextUID(); + if ($this->GIDNumInUse($next_uid)) { + throw new Exception("UID/GID Mismatch"); + } + + return $next_uid; + } + + public function getAllUsers($services) + { + $users = $this->userOU->getChildren(true); + + $out = array(); + foreach ($users as $user) { + array_push($out, new unityUser($user->getAttribute("cn")[0], $services)); + } + + return $out; + } +} diff --git a/resources/libraries/unity-service.php b/resources/libraries/unity-service.php new file mode 100644 index 00000000..3ba1a18a --- /dev/null +++ b/resources/libraries/unity-service.php @@ -0,0 +1,170 @@ + array(), + "sql" => array(), + "mail" => array(), + "unityfs" => array(), + "sacctmgr" => array() + ); + + public function __construct() + { + } + + public function add_ldap($details, $name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["ldap"])) { + throw new Exception("Service '$name' already exists."); + } + + $ldap_object = new unityLDAP($details["uri"], $details["bind_dn"], $details["bind_pass"]); + $this->services["ldap"][$name] = $ldap_object; + + return $this; + } + + public function add_sql($details, $name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["sql"])) { + throw new Exception("Service '$name' already exists."); + } + + $sql_object = new unitySQL($details["host"], $details["db"], $details["user"], $details["pass"]); + $this->services["sql"][$name] = $sql_object; + + return $this; + } + + public function add_mail($details, $name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["mail"])) { + throw new Exception("Service '$name' already exists."); + } + + if (!array_key_exists("template_path", $details)) { + throw new Exception("Template path not set."); + } + $mailer = new templateMailer($details["template_path"]); + + $mailer->isSMTP(); + //$mailer->SMTPDebug = 4; // DEBUG + + if (!array_key_exists("host", $details)) { + throw new Exception("Hostname not set."); + } + $mailer->Host = $details["host"]; + + if (!array_key_exists("port", $details)) { + throw new Exception("Port not set"); + } + $mailer->Port = $details["port"]; + + if (!array_key_exists("smtp_options", $details)) { + $mailer->SMTPOptions = array( + 'ssl' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ) + ); + } else { + $mailer->SMTPOptions = $details["smtp_options"]; + } + + if (array_key_exists("smtp_secure", $details)) { + $mailer->SMTPSecure = $details["smtp_secure"]; + } + + if (array_key_exists("smtp_user", $details) && array_key_exists("smtp_pass", $details)) { + $mailer->SMTPAuth = true; + $mailer->Username = $details["smtp_user"]; + $mailer->Password = $details["smtp_pass"]; + } else { + $mailer->SMTPAuth = false; + } + + $this->services["mail"][$name] = $mailer; + + return $this; + } + + public function add_sacctmgr($details, $name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["sacctmgr"])) { + throw new Exception("Service '$name' already exists."); + } + + if (!array_key_exists("cluster", $details)) { + throw new Exception("Slurm cluster name must be set."); + } + + $sacctmgr = new slurm($details["cluster"]); + + $this->services["sacctmgr"][$name] = $sacctmgr; + + return $this; + } + + public function add_unityfs($details, $name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["unityfs"])) { + throw new Exception("Service '$name' already exists."); + } + + $unityfs = new unityfs($details["host"], $details["port"]); + + $this->services["unityfs"][$name] = $unityfs; + + return $this; + } + + public function ldap($name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["ldap"])) { + return $this->services["ldap"][$name]; + } else { + return NULL; + } + } + + public function sql($name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["sql"])) { + return $this->services["sql"][$name]; + } else { + return NULL; + } + } + + public function mail($name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["mail"])) { + return $this->services["mail"][$name]; + } else { + return NULL; + } + } + + public function sacctmgr($name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["sacctmgr"])) { + return $this->services["sacctmgr"][$name]; + } else { + return NULL; + } + } + + public function unityfs($name = self::DEFAULT_KEY) + { + if (array_key_exists($name, $this->services["unityfs"])) { + return $this->services["unityfs"][$name]; + } else { + return NULL; + } + } +} diff --git a/resources/libraries/unity-sql.php b/resources/libraries/unity-sql.php new file mode 100644 index 00000000..f65bb53c --- /dev/null +++ b/resources/libraries/unity-sql.php @@ -0,0 +1,83 @@ +conn = new PDO("mysql:host=" . $db_host . ";dbname=" . $db, $db_user, $db_pass); + $this->conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + } + + public function getConn() { + return $this->conn; + } + + // + // requests table methods + // + public function addRequest($requestor, $dest = self::REQUEST_ADMIN) { + if ($this->requestExists($requestor, $dest)) { + throw new Exception("This request already exists"); + } + + $stmt = $this->conn->prepare("INSERT INTO " . self::TABLE_REQS . " (uid, request_for) VALUES (:uid, :request_for)"); + $stmt->bindParam(":uid", $requestor); + $stmt->bindParam(":request_for", $dest); + + $stmt->execute(); + } + + public function removeRequest($requestor, $dest = self::REQUEST_ADMIN) { + if (!$this->requestExists($requestor, $dest)) { + throw new Exception("This request doesn't exist"); + } + + $stmt = $this->conn->prepare("DELETE FROM " . self::TABLE_REQS . " WHERE uid=:uid and request_for=:request_for"); + $stmt->bindParam(":uid", $requestor); + $stmt->bindParam(":request_for", $dest); + + $stmt->execute(); + } + + public function removeRequests($dest = self::REQUEST_ADMIN) { + $stmt = $this->conn->prepare("DELETE FROM " . self::TABLE_REQS . " WHERE request_for=:request_for"); + $stmt->bindParam(":request_for", $dest); + + $stmt->execute(); + } + + public function requestExists($requestor, $dest = self::REQUEST_ADMIN) { + $stmt = $this->conn->prepare("SELECT * FROM " . self::TABLE_REQS . " WHERE uid=:uid and request_for=:request_for"); + $stmt->bindParam(":uid", $requestor); + $stmt->bindParam(":request_for", $dest); + + $stmt->execute(); + + return count($stmt->fetchAll()) > 0; + } + + public function getRequests($dest = self::REQUEST_ADMIN) { + $stmt = $this->conn->prepare("SELECT * FROM " . self::TABLE_REQS . " WHERE request_for=:request_for"); + $stmt->bindParam(":request_for", $dest); + + $stmt->execute(); + + return $stmt->fetchAll(); + } + + public function getRequestsByUser($user) { + $stmt = $this->conn->prepare("SELECT * FROM " . self::TABLE_REQS . " WHERE uid=:uid"); + $stmt->bindParam(":uid", $user); + + $stmt->execute(); + + return $stmt->fetchAll(); + } +} diff --git a/resources/libraries/unity-user.php b/resources/libraries/unity-user.php new file mode 100644 index 00000000..bcecb7c2 --- /dev/null +++ b/resources/libraries/unity-user.php @@ -0,0 +1,357 @@ +uid = $uid; + + if (is_null($service_stack->ldap())) { + throw new Exception("LDAP is required for the unityUser class"); + } + + if (is_null($service_stack->sql())) { + throw new Exception("SQL is required for the unityUser class"); + } + + if (is_null($service_stack->sacctmgr())) { + throw new Exception("sacctmgr is required for the unityUser class"); + } + + if (is_null($service_stack->unityfs())) { + throw new Exception("unityfs is required for the unityUser class"); + } + + $this->service_stack = $service_stack; + } + + /** + * This is the method that is run when a new account is created + * + * @param string $firstname First name of new account + * @param string $lastname Last name of new account + * @param string $email email of new account + * @param bool $isPI boolean value for if the user checked the "I am a PI box" + * @return void + */ + public function init($firstname, $lastname, $email) + { + // + // Create LDAP group + // + $ldapGroupEntry = $this->getLDAPGroup(); + $id = $this->service_stack->ldap()->getUnassignedID($this->getUID()); + + if (!$ldapGroupEntry->exists()) { + $ldapGroupEntry->setAttribute("objectclass", unityLDAP::POSIX_GROUP_CLASS); + $ldapGroupEntry->setAttribute("gidnumber", strval($id)); + + if (!$ldapGroupEntry->write()) { + throw new Exception("Failed to create POSIX group for $this->uid"); + } + } + + // + // Create LDAP user + // + $ldapUserEntry = $this->getLDAPUser(); + + if (!$ldapUserEntry->exists()) { + $ldapUserEntry->setAttribute("objectclass", unityLDAP::POSIX_ACCOUNT_CLASS); + $ldapUserEntry->setAttribute("uid", $this->uid); + $ldapUserEntry->setAttribute("givenname", $firstname); + $ldapUserEntry->setAttribute("sn", $lastname); + $ldapUserEntry->setAttribute("mail", $email); + $ldapUserEntry->setAttribute("homedirectory", self::HOME_DIR . $this->uid); + $ldapUserEntry->setAttribute("loginshell", unityLDAP::DEFAULT_SHELL); + $ldapUserEntry->setAttribute("uidnumber", strval($id)); + $ldapUserEntry->setAttribute("gidnumber", strval($id)); + + if (!$ldapUserEntry->write()) { + $ldapGroupEntry->delete(); // Cleanup previous group + throw new Exception("Failed to create POSIX user for $this->uid"); + } + } + + // + // MySQL row + // + if ($isPI) { + $this->service_stack->sql()->addRequest($this->uid); + } + + // filesystem + $this->initFilesystem(); + } + + + /** + * Returns the ldap account entry corresponding to the user + * + * @return ldapEntry posix account + */ + public function getLDAPUser() + { + $user_entries = $this->service_stack->ldap()->userOU->getChildren(true, "(" . unityLDAP::RDN . "=$this->uid)"); + + if (count($user_entries) > 0) { + return $user_entries[0]; + } else { + return new ldapEntry($this->service_stack->ldap()->getConn(), unityLDAP::RDN . "=$this->uid," . unityLDAP::USERS); + } + } + + /** + * Returns the ldap group entry corresponding to the user + * + * @return ldapEntry posix group + */ + public function getLDAPGroup() + { + $group_entries = $this->service_stack->ldap()->groupOU->getChildren(true, "(" . unityLDAP::RDN . "=$this->uid)"); + + if (count($group_entries) > 0) { + return $group_entries[0]; + } else { + return new ldapEntry($this->service_stack->ldap()->getConn(), unityLDAP::RDN . "=$this->uid," . unityLDAP::GROUPS); + } + } + + public function exists() + { + return $this->getLDAPUser()->exists() && $this->getLDAPGroup()->exists(); + } + + // + // User Attribute Functions + // + + /** + * Get method for NetID + * + * @return string Net ID of user + */ + public function getUID() + { + return $this->uid; + } + + /** + * Sets the firstname of the account and the corresponding ldap entry if it exists + * + * @param string $firstname + */ + public function setFirstname($firstname) + { + $this->getLDAPUser()->setAttribute("givenname", $firstname); + + if (!$this->getLDAPUser()->write()) { + throw new Exception("Error updating LDAP entry $this->uid"); + } + } + + /** + * Gets the firstname of the account + * + * @return string firstname + */ + public function getFirstname() + { + return $this->getLDAPUser()->getAttribute("givenname")[0]; + } + + /** + * Sets the lastname of the account and the corresponding ldap entry if it exists + * + * @param string $lastname + */ + public function setLastname($lastname) + { + $this->getLDAPUser()->setAttribute("sn", $lastname); + + if (!$this->getLDAPUser()->write()) { + throw new Exception("Error updating LDAP entry $this->uid"); + } + } + + /** + * Get method for the lastname on the account + * + * @return string lastname + */ + public function getLastname() + { + return $this->getLDAPUser()->getAttribute("sn")[0]; + } + + public function getFullname() { + return $this->getFirstname() . " " . $this->getLastname(); + } + + /** + * Sets the mail in the account and the ldap entry + * + * @param string $mail + */ + public function setMail($email) + { + $this->getLDAPUser()->setAttribute("mail", $email); + + if (!$this->getLDAPUser()->write()) { + throw new Exception("Error updating LDAP entry $this->uid"); + } + } + + /** + * Method to get the mail instance var + * + * @return string email address + */ + public function getMail() + { + return $this->getLDAPUser()->getAttribute("mail")[0]; + } + + /** + * Sets the SSH keys on the account and the corresponding entry + * + * @param array $keys String array of openssh-style ssh public keys + */ + public function setSSHKeys($keys) + { + $ldapUser = $this->getLDAPUser(); + $keys_filt = array_values(array_unique($keys)); + if ($ldapUser->exists()) { + $ldapUser->setAttribute("sshpublickey", $keys_filt); + if (!$ldapUser->write()) { + throw new Exception("Failed to modify SSH keys for $this->uid"); + } + } + } + + /** + * Returns the SSH keys attached to the account + * + * @return array String array of ssh keys + */ + public function getSSHKeys() + { + $ldapUser = $this->getLDAPUser(); + $result = $ldapUser->getAttribute("sshpublickey"); + if (is_null($result)) { + return array(); + } else { + return $result; + } + } + + /** + * Sets the login shell for the account + * + * @param string $shell absolute path to shell + */ + public function setLoginShell($shell) + { + $ldapUser = $this->getLDAPUser(); + if ($ldapUser->exists()) { + $ldapUser->setAttribute("loginshell", $shell); + if (!$ldapUser->write()) { + throw new Exception("Failed to modify login shell for $this->uid"); + } + } + } + + /** + * Gets the login shell of the account + * + * @return string absolute path to login shell + */ + public function getLoginShell() + { + $ldapUser = $this->getLDAPUser(); + return $ldapUser->getAttribute("loginshell")[0]; + } + + public function setHomeDir($home) + { + $ldapUser = $this->getLDAPUser(); + if ($ldapUser->exists()) { + $ldapUser->setAttribute("homedirectory", $home); + if (!$ldapUser->write()) { + throw new Exception("Failed to modify home directory for $this->uid"); + } + } + } + + /** + * Gets the home directory of the user, the home directory is immutable + * + * @return string path to home directory + */ + public function getHomeDir() + { + return self::HOME_DIR . $this->netid; + } + + /** + * Checks if the current account is an admin (in the sudo group) + * + * @return boolean true if admin, false if not + */ + public function isAdmin() + { + $admins = $this->service_stack->ldap()->adminGroup->getAttribute("memberuid"); + return in_array($this->uid, $admins); + } + + /** + * Checks if current user is a PI + * + * @return boolean true is PI, false if not + */ + public function isPI() + { + return $this->getAccount()->exists(); + } + + public function getAccount() + { + return new unityAccount(unityAccount::getPIUIDfromUID($this->uid), $this->service_stack); + } + + /** + * Gets the groups this user is assigned to, can be more than one + * @return [type] + */ + public function getGroups() + { + $groups = $this->service_stack->sacctmgr()->getAccountsFromUser($this->uid); + + $out = array(); + foreach ($groups as $group) { + array_push($out, new unityAccount($group, $this->service_stack)); + } + return $out; + } + + public function initFilesystem() { + $this->service_stack->unityfs()->createHomeDirectory($this->getUID(), self::HOME_QUOTA); + $this->service_stack->unityfs()->createScratchDirectory($this->getUID()); + $this->service_stack->unityfs()->populateHomeDirectory($this->getUID()); + $this->service_stack->unityfs()->populateScratchDirectory($this->getUID()); + } +} diff --git a/resources/libraries/unityfs.php b/resources/libraries/unityfs.php new file mode 100644 index 00000000..225ae28c --- /dev/null +++ b/resources/libraries/unityfs.php @@ -0,0 +1,74 @@ +s = socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + socket_set_option($this->s, SOL_SOCKET, SO_REUSEADDR, 1); + if (!socket_connect($this->s, $host, $port)) { + throw new Exception("Unable to connect to socket"); + } + } + + public function createHomeDirectory($uid, $quota) { + $request = ""; + $request .= "$uid$uid"; + $request .= "$quota"; + $request .= ""; + + if (!socket_send($this->s, $request, strlen($request), 0)) { + throw new Exception("Could not send data to unityfs"); + } + + $result = socket_read($this->s, 1024); + return $result; + } + + public function populateHomeDirectory($uid) { + $request = ""; + $request .= "/home/$uid"; + $request .= "$uid$uid"; + $request .= "/scratch/$uid"; + $request .= ""; + + if (!socket_send($this->s, $request, strlen($request), 0)) { + throw new Exception("Could not send data to unityfs"); + } + + $result = socket_read($this->s, 1024); + return $result; + } + + public function createScratchDirectory($uid) { + $request = ""; + $request .= "$uid$uid"; + $request .= ""; + + if (!socket_send($this->s, $request, strlen($request), 0)) { + throw new Exception("Could not send data to unityfs"); + } + + $result = socket_read($this->s, 1024); + return $result; + } + + public function populateScratchDirectory($uid) { + $request = ""; + $request .= "/scratch/$uid"; + $request .= "$uid$uid"; + $request .= ""; + + if (!socket_send($this->s, $request, strlen($request), 0)) { + throw new Exception("Could not send data to unityfs"); + } + + $result = socket_read($this->s, 1024); + return $result; + } + + public function close() { + socket_close($this->s); + } +} \ No newline at end of file diff --git a/resources/locale/en.json b/resources/locale/en.json new file mode 100644 index 00000000..3328dffe --- /dev/null +++ b/resources/locale/en.json @@ -0,0 +1,5 @@ +{ + "globals": { + + } +} \ No newline at end of file diff --git a/resources/locale/en.php b/resources/locale/en.php new file mode 100644 index 00000000..e2e2f65e --- /dev/null +++ b/resources/locale/en.php @@ -0,0 +1,164 @@ +__ + * type = err,warn,mes,label,header + */ +class unity_locale { + // + // General + // + public const ERR = "An error occured, please contact the admins."; + public const LABEL_NAME = "Name"; + public const LABEL_USERID = "User ID"; + public const LABEL_ACTIONS = "Actions"; + public const LABEL_MAIL = "Email"; + public const LABEL_SUBJECT = "Subject"; + public const LABEL_MESSAGE = "Message"; + public const LABEL_APPLY = "Apply Changes"; + public const MES_APPLY = "Changes Applied"; + + public const LABEL_ERROR = "Error:"; + public const LABEL_SUCCESS = "Success:"; + + // + // Home (/index.php) + // + public const HOME_HEADER_MAIN = "Unity Cluster"; + + // + // Authentication (/panel-auth.php) + // + public const AUTH_HEADER_MAIN = "Campus Authentication"; + + // + // AUP/Priv (/priv.php) + // + public const PRIV_HEADER_PRIVACY = "Privacy Policy"; + public const PRIV_HEADER_AUP = "Acceptable User Policy"; + + // + // About (/about.php) + // + public const ABOUT_HEADER_MAIN = "About Us"; + + // + // Contact Us (/contact.php) + // + public const CONTACT_HEADER_MAIN = "Contact Us"; + public const CONTACT_ERR_NAME = "Enter a name"; + public const CONTACT_ERR_MAIL = "Enter a valid email"; + public const CONTACT_ERR_SUBJECT = "Enter a subject"; + public const CONTACT_ERR_MESSAGE = "Enter a message"; + public const CONTACT_LABEL_SEND = "Send Message"; + public const CONTACT_MES_SENT = "Message sent"; + public const CONTACT_WARN_SEND = "Are you sure you would like to send this message?"; + + // + // Cluster Status (/cluster-status.php) + // + public const CLUSTER_HEADER_MAIN = "Cluster Status"; + public const CLUSTER_LABEL_USAGE = "Total CPU Usage"; + public const CLUSTER_LABEL_UP = "UP"; + public const CLUSTER_LABEL_DOWN = "DOWN"; + + // + // Panel Home (/panel/index.php) + // + public const PANEL_HEADER_MAIN = "User Panel"; + + // + // New Account (/panel/new_account.php) + // + public const NEWACC_HEADER_MAIN = "Request Account"; + public const NEWACC_LABEL_SELECTPI = " -- Select Principal Investigator -- "; + public const NEWACC_LABEL_EXISTINGPI = " -- Existing PI --"; + public const NEWACC_ERR_PI = "Select a valid PI, or select \"New PI\""; + public const NEWACC_LABEL_ARCHIVAL = "You are an archived account. This could be due to you closing your account, or your account being removed. Please select a PI to request to restore your account. Your files are archived and will be restored upon approval."; + public const NEWACC_LABEL_AWAITING = "Your account request has been submitted but it has not yet been approved. You will not be able to submit this form."; + public const NEWACC_LABEL_REQUEST = "Request Account"; + public const NEWACC_MES_SUCCESS = "Your account request has been submitted. You will receive email confirmation once your account is created."; + + // + // Account Settings (/panel/account.php) + // + public const ACCOUNT_HEADER_MAIN = "Account Settings"; + public const ACCOUNT_LABEL_NEW = "Add New Key"; + public const ACCOUNT_LABEL_GENERATE = "Generate Key Pair"; + public const ACCOUNT_LABEL_GENWIN = "Windows (PuTTY)"; + public const ACCOUNT_LABEL_GENLIN = "Linux / Max (OpenSSH)"; + public const ACCOUNT_LABEL_REMKEY = "Remove SSH Key"; + public const ACCOUNT_LABEL_KEY = "ssh-rsa AAAAB3Nza..."; + + // + // My Groups (/panel/groups.php) + // + public const GROUP_HEADER_MAIN = "My Groups"; + public const GROUP_BTN_JOIN_PI = "Join PI"; + public const GROUP_BTN_ACTIVATE_PI = "Activate PI Account"; + public const GROUP_WARN_ACTIVATE_PI = "Are you sure you want to activate your PI account? You need to be a PI at your institution for this request."; + + public static function GROUP_WARN_REMOVE($name) { + return "Are you sure you would like to leave the PI group $name?"; + } + + // + // Mass Email (/admin/mass_email.php) + // + public const MASS_HEADER_MAIN = "Mass Email"; + public const MASS_ERR_SUBJECT = "Enter a valid subject"; + public const MASS_ERR_MESSAGE = "Enter a valid message"; + public const MASS_WARN_SEND = "Are you sure you would like to send this mass email?"; + public const MASS_LABEL_SEND = "Send Mass Email"; + public const MASS_MES_SEND = "Mass email was sent successfully"; + + // + // User Management (/admin/user-mgmt.php) + // + public const USER_HEADER_MAIN = "User Management"; + + // + // Mail Templates + // + public static function MAIL_LABEL_FOOTER($url) { + return "You are receiving this email because you have an account on the Unity Cluster at UMass Amherst. If you would like to stop receiving these emails, you may request to close your account by replying to this email."; + } + + public const MAIL_HEADER_PIREQUEST = "New User Request"; + public const MAIL_LABEL_PIREQUEST = "A user has requested an account in your PI group:"; + + public const MAIL_HEADER_ADREQUEST = "New PI Request"; + public const MAIL_LABEL_ADREQUEST = "A user has requested a new PI account:"; + + public const MAIL_HEADER_PIJOIN = "PI Request Approved"; + public static function MAIL_LABEL_PIJOIN($group) { + return "Your request to join the PI group $group has been approved. You may view this group in in the 'My Groups' page on the user portal"; + } + + public const MAIL_HEADER_PIDENY = "PI Request Denied"; + public static function MAIL_LABEL_PIDENY($group) { + return "Your request to join the PI group $group has been denied. You may follow up with the group owner if necessary."; + } + + public const MAIL_HEADER_PIREM = "Removed from Group"; + public static function MAIL_LABEL_PIREM($group) { + return "You have been removed from the PI group $group. Your files have not been removed, only your association with $group. Feel free to reply with any questions."; + } + + public const MAIL_HEADER_ADMIN_APP_PI = "PI Account Approved"; + public const MAIL_LABEL_ADMIN_APP_PI = "Your request to create a PI account has been approved. You can manage your group on the 'PI Management' page in the user portal."; + + public const MAIL_HEADER_ADMIN_DENY_PI = "PI Account Denied"; + public const MAIL_LABEL_ADMIN_DENY_PI = "Your request to create a PI account has been denied. This could be for a number of reasons. Most comonly, you may not be a principal investigator at your institution. Feel free to reply to this email with any questions."; + + public const MAIL_HEADER_ADMIN_DISBAND_PI = "PI Account Disbanded"; + public const MAIL_LABEL_ADMIN_DISBAND_PI = "Your PI group has been disbanded. This means you and all users part of the group no longer have an association with it. Your user accounts and files are still intact, as they are seperate from PI accounts"; + + public const MAIL_HEADER_LEFT_PI = "User Left PI Group"; + public const MAIL_LABEL_LEFT_PI = "A user has left your PI group, details below:"; + + public static function MAIL_MES_ACTIVATE($url) { + return "You can approve this account here."; + } +} \ No newline at end of file diff --git a/resources/templates/footer.php b/resources/templates/footer.php new file mode 100644 index 00000000..c74ccd82 --- /dev/null +++ b/resources/templates/footer.php @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/resources/templates/globals.php b/resources/templates/globals.php new file mode 100644 index 00000000..17eb0452 --- /dev/null +++ b/resources/templates/globals.php @@ -0,0 +1,61 @@ +here to continue."); + } +} + +function printMessages(&$errors, $str_success) +{ + if (isset($errors)) { + echo "
"; + if (empty($errors)) { + echo "$str_success"; // Success Message + } else { + foreach ($errors as $err) { + echo "$err"; + } + } + echo "
"; + } +} + +function EPPN_to_uid($eppn) { + $eppnExpanded = explode("@", $eppn); + return $eppnExpanded[0] . "_" . str_replace(".", "_", $eppnExpanded[1]); +} + +function getGithubKeys($username) { + $url = "https://api.github.com/users/$username/keys"; + $headers = array( + "User-Agent: Unity Cluster User Portal" + ); + + $curl = curl_init(); + curl_setopt($curl, CURLOPT_URL, $url); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($curl, CURLOPT_HTTPHEADER, $headers); + $output = json_decode(curl_exec($curl), true); + curl_close($curl); + + $out = array(); + foreach ($output as $value) { + array_push($out, $value["key"]); + } + + return $out; +} + +function removeTrailingWhitespace($arr) { + $out = array(); + foreach ($arr as $str) { + $new_string = rtrim($str); + array_push($out, $new_string); + } + + return $out; +} \ No newline at end of file diff --git a/resources/templates/header.php b/resources/templates/header.php new file mode 100644 index 00000000..28bc375b --- /dev/null +++ b/resources/templates/header.php @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + "> + + <?php echo config::CLUSTER["name"]; ?> • <?php echo config::CLUSTER["org"]; ?> + + + + +
+ + " target="_blank" class="unity-state">Beta + +
+ + + + + + +
\ No newline at end of file diff --git a/resources/templates/mail/admin_approve_pi.php b/resources/templates/mail/admin_approve_pi.php new file mode 100644 index 00000000..ed971117 --- /dev/null +++ b/resources/templates/mail/admin_approve_pi.php @@ -0,0 +1,10 @@ +setFrom(config::MAIL["addresses"]["sender"][0], config::MAIL["addresses"]["sender"][1]); +$this->addReplyTo(config::MAIL["addresses"]["contact"][0], config::MAIL["addresses"]["contact"][1]); +$this->addAddress($data["to"]); +$this->Subject = unity_locale::MAIL_HEADER_ADMIN_APP_PI; + +echo unity_locale::MAIL_LABEL_ADMIN_APP_PI; + +include "footer.php"; +?> \ No newline at end of file diff --git a/resources/templates/mail/admin_deny_pi.php b/resources/templates/mail/admin_deny_pi.php new file mode 100644 index 00000000..c494e78c --- /dev/null +++ b/resources/templates/mail/admin_deny_pi.php @@ -0,0 +1,10 @@ +setFrom(config::MAIL["addresses"]["sender"][0], config::MAIL["addresses"]["sender"][1]); +$this->addReplyTo(config::MAIL["addresses"]["contact"][0], config::MAIL["addresses"]["contact"][1]); +$this->addAddress($data["to"]); +$this->Subject = unity_locale::MAIL_HEADER_ADMIN_DENY_PI; + +echo unity_locale::MAIL_LABEL_ADMIN_DENY_PI; + +include "footer.php"; +?> \ No newline at end of file diff --git a/resources/templates/mail/admin_disband_pi.php b/resources/templates/mail/admin_disband_pi.php new file mode 100644 index 00000000..1f240c05 --- /dev/null +++ b/resources/templates/mail/admin_disband_pi.php @@ -0,0 +1,10 @@ +setFrom(config::MAIL["addresses"]["sender"][0], config::MAIL["addresses"]["sender"][1]); +$this->addReplyTo(config::MAIL["addresses"]["contact"][0], config::MAIL["addresses"]["contact"][1]); +$this->addAddress($data["to"]); +$this->Subject = unity_locale::MAIL_HEADER_ADMIN_DISBAND_PI; + +echo unity_locale::MAIL_LABEL_ADMIN_DISBAND_PI; + +include "footer.php"; +?> \ No newline at end of file diff --git a/resources/templates/mail/deny_pi.php b/resources/templates/mail/deny_pi.php new file mode 100644 index 00000000..9cc78c0f --- /dev/null +++ b/resources/templates/mail/deny_pi.php @@ -0,0 +1,10 @@ +setFrom(config::MAIL["addresses"]["sender"][0], config::MAIL["addresses"]["sender"][1]); +$this->addReplyTo(config::MAIL["addresses"]["contact"][0], config::MAIL["addresses"]["contact"][1]); +$this->addAddress($data["to"]); +$this->Subject = unity_locale::MAIL_HEADER_PIDENY; + +echo unity_locale::MAIL_LABEL_PIDENY($data["group"]); + +include "footer.php"; +?> \ No newline at end of file diff --git a/resources/templates/mail/footer.php b/resources/templates/mail/footer.php new file mode 100644 index 00000000..baa2dafe --- /dev/null +++ b/resources/templates/mail/footer.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/resources/templates/mail/join_pi.php b/resources/templates/mail/join_pi.php new file mode 100644 index 00000000..8ac23340 --- /dev/null +++ b/resources/templates/mail/join_pi.php @@ -0,0 +1,10 @@ +setFrom(config::MAIL["addresses"]["sender"][0], config::MAIL["addresses"]["sender"][1]); +$this->addReplyTo(config::MAIL["addresses"]["contact"][0], config::MAIL["addresses"]["contact"][1]); +$this->addAddress($data["to"]); +$this->Subject = unity_locale::MAIL_HEADER_PIJOIN; + +echo unity_locale::MAIL_LABEL_PIJOIN($data["group"]); + +include "footer.php"; +?> \ No newline at end of file diff --git a/resources/templates/mail/left_user.php b/resources/templates/mail/left_user.php new file mode 100644 index 00000000..536fd8a9 --- /dev/null +++ b/resources/templates/mail/left_user.php @@ -0,0 +1,14 @@ +setFrom(config::MAIL["addresses"]["sender"][0], config::MAIL["addresses"]["sender"][1]); +$this->addReplyTo(config::MAIL["addresses"]["contact"][0], config::MAIL["addresses"]["contact"][1]); +$this->addAddress($data["to"]); +$this->Subject = unity_locale::MAIL_HEADER_LEFT_PI; + +echo "

" . unity_locale::MAIL_LABEL_LEFT_PI . "

"; + +echo "

" . unity_locale::LABEL_USERID . " " . $data["netid"] . "

"; +echo "

" . unity_locale::LABEL_NAME . " " . $data["firstname"] . " " . $data["lastname"] . "

"; +echo "

" . unity_locale::LABEL_MAIL . " " . $data["mail"] . "

"; + +include "footer.php"; +?> \ No newline at end of file diff --git a/resources/templates/mail/new_group_request.php b/resources/templates/mail/new_group_request.php new file mode 100644 index 00000000..fb75339c --- /dev/null +++ b/resources/templates/mail/new_group_request.php @@ -0,0 +1,16 @@ +setFrom(config::MAIL["addresses"]["sender"][0], config::MAIL["addresses"]["sender"][1]); +$this->addReplyTo(config::MAIL["addresses"]["contact"][0], config::MAIL["addresses"]["contact"][1]); +$this->addAddress($data["to"]); +$this->Subject = unity_locale::MAIL_HEADER_PIREQUEST; + +echo "

" . unity_locale::MAIL_LABEL_PIREQUEST . "

"; + +echo "

" . unity_locale::LABEL_USERID . " " . $data["netid"] . "

"; +echo "

" . unity_locale::LABEL_NAME . " " . $data["firstname"] . " " . $data["lastname"] . "

"; +echo "

" . unity_locale::LABEL_MAIL . " " . $data["mail"] . "

"; + +echo "

" . unity_locale::MAIL_MES_ACTIVATE(config::URL . "/panel/pi.php") . "

"; + +include "footer.php"; +?> \ No newline at end of file diff --git a/resources/templates/mail/new_pi_request.php b/resources/templates/mail/new_pi_request.php new file mode 100644 index 00000000..7e5ff057 --- /dev/null +++ b/resources/templates/mail/new_pi_request.php @@ -0,0 +1,15 @@ +setFrom(config::MAIL["addresses"]["sender"][0], config::MAIL["addresses"]["sender"][1]); +$this->addAddress(config::MAIL["addresses"]["contact"][0], config::MAIL["addresses"]["contact"][1]); +$this->Subject = unity_locale::MAIL_HEADER_ADREQUEST; + +echo "

" . unity_locale::MAIL_LABEL_ADREQUEST . "

"; + +echo "

" . unity_locale::LABEL_USERID . " " . $data["netid"] . "

"; +echo "

" . unity_locale::LABEL_NAME . " " . $data["firstname"] . " " . $data["lastname"] . "

"; +echo "

" . unity_locale::LABEL_MAIL . " " . $data["mail"] . "

"; + +echo "

" . unity_locale::MAIL_MES_ACTIVATE(config::URL . "/admin/user-mgmt.php") . "

"; + +include "footer.php"; +?> \ No newline at end of file diff --git a/resources/templates/mail/rem_pi.php b/resources/templates/mail/rem_pi.php new file mode 100644 index 00000000..da3a5234 --- /dev/null +++ b/resources/templates/mail/rem_pi.php @@ -0,0 +1,10 @@ +setFrom(config::MAIL["addresses"]["sender"][0], config::MAIL["addresses"]["sender"][1]); +$this->addReplyTo(config::MAIL["addresses"]["contact"][0], config::MAIL["addresses"]["contact"][1]); +$this->addAddress($data["to"]); +$this->Subject = unity_locale::MAIL_HEADER_PIREM; + +echo unity_locale::MAIL_LABEL_PIREM($data["group"]); + +include "footer.php"; +?> \ No newline at end of file diff --git a/vhost.conf.example b/vhost.conf.example new file mode 100644 index 00000000..dd36bb6e --- /dev/null +++ b/vhost.conf.example @@ -0,0 +1,78 @@ + + + ServerName + ServerAlias www. + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + # Force redirect to SSL + RewriteEngine On + RewriteCond %{HTTPS} !=on + RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L] + + + + + + + ServerName + ServerAlias www. + + DocumentRoot /srv/www/unity-web/webroot + + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + ### SSL ### + SSLEngine on + SSLCertificateFile + SSLCertificateKeyFile + SSLProtocol -ALL +TLSv1.2 + SSLCipherSuite ALL:!aNULL:!ADH:!eNULL:!LOW:!EXP:RC4+RSA:+HIGH:+MEDIUM + + + SSLOptions +StdEnvVars + + + SSLOptions +StdEnvVars + + + Alias /docs # Alias for docs page + + Alias /admin/ # Not required - just an example + + ### User Level Access ### + + AuthType shibboleth + ShibRequestSetting requireSession 1 + ShibRequestSetting redirectToSSL 443 + require valid-user + + + ### Admin Level Access ### + + AuthType shibboleth + ShibRequestSetting requireSession 1 + ShibRequestSetting redirectToSSL 443 + require shib-attr eppn + + + RequestHeader set X-Remote-User %{REMOTE_USER}s # For JupyterLab authentication + + RewriteEngine on + ProxyPreserveHost on + + ### Jupyter Hub ### + RewriteCond %{HTTP:Connection} Upgrade [NC] + RewriteCond %{HTTP:Upgrade} websocket [NC] + RewriteRule /panel/jhub/(.*) ws://127.0.0.1:8000/panel/jhub/$1 [P,L] + RewriteRule /panel/jhub/(.*) http://127.0.0.1:8000/panel/jhub/$1 [P,L] + + + ProxyPass http://127.0.0.1:8000/panel/jhub + ProxyPassReverse http://127.0.0.1:8000/panel/jhub + + + + \ No newline at end of file diff --git a/webroot/admin/scripts/init_datasets.php b/webroot/admin/scripts/init_datasets.php new file mode 100644 index 00000000..de303ec6 --- /dev/null +++ b/webroot/admin/scripts/init_datasets.php @@ -0,0 +1,15 @@ +ldap()->getAllUsers($SERVICE); // get all users + +//var_dump($users); + +//$storage->createHomeDirectory("hsaplakoglu_umass_edu"); +//$storage->deleteHomeDirectory("hsaplakoglu_umass_edu"); + +foreach ($users as $user) { + $user->initHomeDirectory(); +} \ No newline at end of file diff --git a/webroot/admin/tests/github_keys.php b/webroot/admin/tests/github_keys.php new file mode 100644 index 00000000..956b1471 --- /dev/null +++ b/webroot/admin/tests/github_keys.php @@ -0,0 +1,5 @@ + + + + +
+

This is a h1

+

This is a h2

+

This is a h3

+

This is a h4

+
This is a h5
+
This is a h6
+

This is a paragraph

+ This is a link +
+ +
+ + \ No newline at end of file diff --git a/webroot/admin/tests/truenas_integration.php b/webroot/admin/tests/truenas_integration.php new file mode 100644 index 00000000..f20bb0ee --- /dev/null +++ b/webroot/admin/tests/truenas_integration.php @@ -0,0 +1,8 @@ +createHomeDirectory("hsaplakoglu_umass_edu"); +$storage->deleteHomeDirectory("hsaplakoglu_umass_edu"); \ No newline at end of file diff --git a/webroot/admin/user-mgmt.php b/webroot/admin/user-mgmt.php new file mode 100644 index 00000000..efaa9510 --- /dev/null +++ b/webroot/admin/user-mgmt.php @@ -0,0 +1,192 @@ +isAdmin()) { + die(); +} + +if ($_SERVER["REQUEST_METHOD"] == "POST") { + + if (isset($_POST["uid"])) { + $form_user = new unityUser($_POST["uid"], $SERVICE); + } + + switch ($_POST["form_name"]) { + case "approveReq": + $group = $form_user->getAccount(); + if (!$form_user->isPI()) { + $group->createGroup(); + } + + $SERVICE->sql()->removeRequest($form_user->getUID()); + + $SERVICE->mail()->send("admin_approve_pi", array("to" => $form_user->getMail())); + + // (1) Create Slurm Account + // (2) Create LDAP Group + // (3) Remove SQL Row for Request + // (4) Send email to new PI + break; + case "denyReq": + $SERVICE->sql()->removeRequest($form_user->getUID()); + + $SERVICE->mail()->send("admin_deny_pi", array("to" => $form_user->getMail())); + + // (1) Remove SQL Row request + // (2) Send email to requestor + break; + case "remUser": + $remGroup = new unityAccount($_POST["pi"], $SERVICE); + + if ($remGroup->exists()) { + foreach ($remGroup->getGroupMembers() as $member) { + $remGroup->removeUserFromGroup($member); + + $SERVICE->mail()->send("rem_pi", array("to" => $member->getMail(), "group" => $remGroup->getPIUID())); + } + } + $remGroup->removeGroup(); + + $SERVICE->mail()->send("admin_disband_pi", array("to" => $remGroup->getOwner()->getMail())); + + // (same as disband PI from pi.php), except also send email to PI + break; + case "approveReqChild": + // approve request button clicked + $parent = new unityAccount($_POST["pi"], $SERVICE); + + $parent->addUserToGroup($form_user); // Add to group (ldap and slurm) + + try { + $parent->removeRequest($form_user->getUID()); // remove request from db + } catch (Exception $e) { + $parent->removeUserFromGroup($form_user); // roll back + echo $e->getMessage(); // ! DEBUG + } + + $SERVICE->mail()->send("join_pi", array("to" => $form_user->getMail(), "group" => $parent->getPIUID())); + + // (1) Create slurm association [DONE] + // (2) Remove SQL Row if (1) succeeded [DONE] + // (3) Send email to requestor + break; + case "denyReqChild": + // deny request button clicked + + $parent = new unityAccount($_POST["pi"], $SERVICE); + + $parent->removeRequest($form_user->getUID()); // remove request from db + + $SERVICE->mail()->send("deny_pi", array("to" => $form_user->getMail(), "group" => $parent->getPIUID())); + + // (1) Remove SQL Row + // (2) Send email to requestor + break; + case "remUserChild": + // remove user button clicked + + $parent = new unityAccount($_POST["pi"], $SERVICE); + + $parent->removeUserFromGroup($form_user); + + $SERVICE->mail()->send("rem_pi", array("to" => $form_user->getMail(), "group" => $parent->getPIUID())); + $SERVICE->mail()->send("left_user", array("netid" => $form_user->getUID(), "firstname" => $form_user->getFirstname(), "lastname" => $form_user->getLastname(), "mail" => $form_user->getMail(), "to" => $parent->getOwner()->getMail())); + + // (1) Remove slurm association + // (2) Send email to removed user + break; + } +} + +include config::PATHS["templates"] . "/header.php"; +?> + +

Admin User Panel

+ + + + + + + + + + sql()->getRequests(); + + foreach ($requests as $request) { + $request_user = new unityUser($request["uid"], $SERVICE); + + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + + $accounts = $SERVICE->sacctmgr()->getAccounts(); + + foreach ($accounts as $account) { + $pi_group = new unityAccount($account, $SERVICE); + $pi_user = $pi_group->getOwner(); + + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + + foreach ($pi_group->getGroupMembers() as $child) { + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + + foreach ($pi_group->getRequests() as $child_request) { + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + } + ?> +
NameUnity IDMailActions
" . $request_user->getFirstname() . " " . $request_user->getLastname() . "" . $request_user->getUID() . "" . $request_user->getMail() . ""; + echo "
"; + echo "
"; + echo "
" . $pi_user->getFirstname() . " " . $pi_user->getLastname() . "" . $pi_group->getPIUID() . "" . $pi_user->getMail() . ""; + echo "
"; + echo "
" . $child->getFirstname() . " " . $child->getLastname() . "" . $child->getUID() . "" . $child->getMail() . ""; + echo "
"; + echo "
" . $child_request->getFirstname() . " " . $child_request->getLastname() . "" . $child_request->getUID() . "" . $child_request->getMail() . ""; + echo "
"; + echo "
"; + echo "
+ + + + \ No newline at end of file diff --git a/webroot/css/global.css b/webroot/css/global.css new file mode 100644 index 00000000..278a4e0f --- /dev/null +++ b/webroot/css/global.css @@ -0,0 +1,234 @@ +html, body { + margin: 0; + font-family: Arial, Helvetica, sans-serif; + background: var(--color-background); + color: var(--color-text-dark); + height: 100%; + overscroll-behavior-y: none; + font-size: 11pt; + color: var(--color-text-dark); + box-sizing: border-box; +} + +body { + display: flex; + flex-direction: column; +} + +/* +------------- + GLOBALS +------------- +*/ + +a { + color: var(--color-foreground); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +h1 { + font-size: 18pt; + font-weight: bold; + padding-bottom: 5px; + display: block; + margin: 0; +} + +h2 { + font-size: 16pt; + font-weight: bold; + padding-bottom: 5px; + display: block; + margin: 0; +} + +h3 { + font-size: 16pt; + font-weight: unset; + padding-bottom: 5px; + display: block; + margin: 0; +} + +h4 { + font-size: 15pt; + font-weight: unset; + padding-bottom: 5px; + font-style: italic; + display: block; + margin: 0; +} + +h5 { + font-size: 12pt; + font-weight: unset; + color: var(--color-background-text-inactive); + display: block; + padding: 0; + margin: 0; +} + +hr { + border-top: 0; + border-right: 0; + border-left: 0; + border-bottom: 1px solid var(--color-background-dividers); + margin: 10px 0 10px 0; + display: block; +} + +.vertical-align { + position: absolute; + top: 50%; + transform: translateY(-50%); +} + +button.plusBtn { + font-size: 24pt; + display: block; + width: 100%; + padding: 0; + overflow: hidden; +} + +pre { + background: var(--color-background-panel-active); + display: inline; + border-radius: 3px; + padding: 3px 6px 3px 6px; +} + +/* Form Elements */ + +form>* { + display: block; +} + +form > *:not(:first-child) { + margin-top: 5px; +} + +input:focus, textarea:focus { + outline: var(--color-foreground) solid 2px; +} + +button:focus, input[type=submit]:focus { + outline: none; +} + +input[type=text], input[type=password] { + margin: 10px 0 10px 0; + border: 1px solid var(--color-background-dividers); + padding: 5px; + width: calc(100% - 12px); + /* Factors in extra border and padding */ + max-width: 300px; + border-radius: 5px; +} + +input[type=submit], button { + background: var(--color-foreground); + color: var(--color-foreground-text); + cursor: pointer; + border: 0; + padding: 8px 12px 8px 12px; + border-radius: 5px; + transition: background 0.1s; +} + +input[type=submit]:hover, button:hover { + background: var(--color-foreground-hover); +} + +input[type=submit]:disabled, button:disabled { + background: var(--color-foreground-disabled); + cursor: default; +} + +input[type=checkbox], input[type=radio] { + display: inline-block; +} + +label { + display: inline; + user-select: none; + font-size: 10pt; + color: var(--color-background-text-inactive); + overflow: hidden; + margin: 0 10px 0 10px; +} + +input[type=radio] { + margin-bottom: 0; +} + +select { + border: 1px solid var(--color-background-dividers); + background: white; + padding: 5px; + width: 100%; + max-width: 300px; + border-radius: 5px; +} + +textarea { + border: 1px solid var(--color-background-dividers); + width: calc(100% - 12px); + min-height: 150px; + display: block; + margin: 10px 0 10px 0; + resize: vertical; + font-family: Arial, Helvetica, sans-serif; + font-size: 10pt; + border-radius: 5px; + padding: 5px; +} + +div.inline * { + display: inline; + margin-right: 10px; +} + +/* +--------------------- + Global Elements +--------------------- +*/ + +/* WRAPPERS */ + +main { + flex: 1 0 auto; + padding: 15px 20px 20px 20px; + max-width: 1000px; + line-height: 24px; +} + +/* FOOTER */ + +footer { + background: var(--color-background-panel); + flex-shrink: 0; + text-align: center; + font-size: 8pt; + color: var(--color-background-panel-inactive); +} + +footer #footerLogos img { + margin: 10px 0 0 0; + padding: 0 10px 0 10px; + height: 20px; +} + +footer #footerLogos>*:not(:last-child) { + border-right: 1px solid var(--color-background-panel-inactive); +} + +.footerBlock { + display: block; + margin: 10px; +} \ No newline at end of file diff --git a/webroot/css/modal.css b/webroot/css/modal.css new file mode 100644 index 00000000..0959425e --- /dev/null +++ b/webroot/css/modal.css @@ -0,0 +1,93 @@ +div.modalWrapper { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 100; +} + +div.modalContent { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--color-background); + padding: 15px; + width: calc(100% - 30px); + box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.4); + border-radius: 5px; + max-width: 600px; +} + +div.modalTitleWrapper { + overflow: hidden; + display: inline-block; +} + +span.modalTitle { + font-size: 13pt; +} + +div.modalMessages { + color: var(--color-text-failure); + font-size: 11pt; +} + +div.modalMessages > * { + margin-top: 7px; + display: block; +} + +div.modalBody > * { + margin: 0; +} + +/* Search Box CSS */ +div.searchWrapper { + background: var(--color-background); + position: absolute; + top: 36px; + z-index: 200; + width: calc(100% - 4px); + overflow: hidden; + font-size: 11pt; + user-select: none; + border-radius: 0 0 5px 5px; + border-bottom: 2px solid var(--color-foreground); + border-left: 2px solid var(--color-foreground); + border-right: 2px solid var(--color-foreground); +} + +div.searchWrapper > * { + display: block; + cursor: pointer; + padding: 4px 10px 4px 10px; +} + +div.searchWrapper > *:hover { + background: var(--color-background-panel-active); +} + +.btnClose { + width: 30px; + height: 30px; + padding: 0; + text-align: center; + font-size: 20pt; +} + + +.btnClose::before { + content: "\00d7"; +} + +.buttonList { + margin-top: 10px; +} + +.buttonList > * { + display: inline-block; + margin-right: 10px; +} \ No newline at end of file diff --git a/webroot/css/navbar.css b/webroot/css/navbar.css new file mode 100644 index 00000000..a6e1235e --- /dev/null +++ b/webroot/css/navbar.css @@ -0,0 +1,120 @@ +/* Navbar */ + +nav.mainNav { + background: var(--color-foreground); + position: fixed; + left: 0; + top: 45px; + font-size: 11pt; + overflow: hidden; + white-space: nowrap; + z-index: 100; +} + +nav.mainNav a { + color: var(--color-foreground-text); + background: #941e1e; + border-radius: 10px; + display: block; + margin: 10px 8px 10px 8px; + padding: 8px; + text-decoration: none; + cursor: pointer; + transition: background 0.1s; +} + +nav.mainNav a:hover { + text-decoration: none; + background: var(--color-foreground-hover); +} + +nav.mainNav a.active { + background: var(--color-foreground-active); +} + +header { + background: var(--color-foreground); + height: 50px; + width: 250px; + position: fixed; + top: 0; + left: 0; +} + +header>#imgLogo { + height: 65%; + margin-left: 10px; + margin-top: 8px; +} + +header>a.unity-state { + background: var(--color-foreground-text); + color: var(--color-foreground); + display: block; + position: absolute; + top: 8px; + left: 140px; + padding: 1px 5px 1px 5px; + font-weight: bold; + font-size: 10pt; + border-radius: 5px; +} + +header>a.unity-state:hover { + text-decoration: none; +} + +header>button.hamburger { + background: var(--color-foreground); + padding: 0; + right: 23px; + height: 60%; +} + +header>button.hamburger>img { + position: relative; + height: 100%; +} + +/* MOBILE VIEW */ + +@media only screen and (max-width: 1000px) { + nav.mainNav { + right: 0; + } + header { + width: 100%; + } + main { + /* Header element height + 2*element padding */ + margin-top: 45px; + } +} + +/* DESKTOP VIEW */ + +@media only screen and (min-width: 1001px) { + nav.mainNav { + bottom: 0; + width: 250px; + } + header>button.hamburger { + display: none; + } + main { + margin-left: 250px; + } + footer { + margin-left: 250px; + } +} + +/* Only show MGHPCC overlay image if height and width support it */ + +@media only screen and (min-width: 1001px) and (min-height: 1000px) { + nav.mainNav { + background: url("/res/mghpcc-image.png"), var(--color-foreground; + background-position: center bottom; + background-repeat: no-repeat; + } +} \ No newline at end of file diff --git a/webroot/css/tables.css b/webroot/css/tables.css new file mode 100644 index 00000000..b0e04e63 --- /dev/null +++ b/webroot/css/tables.css @@ -0,0 +1,100 @@ +table { + border-collapse: collapse; + width: 100%; + max-width: 1000px; +} + +table tr, table td { + padding: 8px 10px 8px 10px; + overflow: hidden; + white-space: nowrap; +} + +table tr { + border-bottom: 5px solid transparent; +} + +table button, table input[type=submit] { + padding: 5px 10px 5px 10px; +} + +table form { + display: inline-block; +} + +table form:not(:first-child) { + margin-left: 10px; +} + +table form>* { + margin: 0; +} + +tr.expanded { + border-bottom: 0; +} + +tr.expanded > td { + background: #eeeeee; +} + +tr.expandable { + cursor: pointer; +} + +tr.expanded.first { + border-bottom: 5px solid var(--color-background-dividers); +} + +tr.expanded.first > td:first-child { + border-top-left-radius: 10px; +} + +tr.expanded.first > td:last-child { + border-top-right-radius: 10px; +} + +tr.expanded.last > td:first-child { + border-bottom-left-radius: 10px; +} + +tr.expanded.last > td:last-child { + border-bottom-right-radius: 10px; +} + +tr.expanded.last { + border-bottom: 10px solid transparent; +} + +tr.expanded:not(.expandable) { + padding-top: 0; + padding-bottom: 0; +} + +tr.expanded:not(.expandable) > td:first-child { + padding-left: 30px; +} + +button.btnExpanded { + transform: rotate(90deg); +} + +/* Buttons */ +button.btnExpand { + margin: 0 10px 0 0; + padding: 0; + background: transparent; + color: var(--color-foreground); +} + +button.btnExpand:hover { + background: transparent; +} + +button.btnExpanded { + transform: rotate(90deg); +} + +td:last-child > button { + float: right; +} \ No newline at end of file diff --git a/webroot/css/vars.css b/webroot/css/vars.css new file mode 100644 index 00000000..425d13b3 --- /dev/null +++ b/webroot/css/vars.css @@ -0,0 +1,21 @@ +:root { + --color-background: #ffffff; /* Site background color */ + --color-background-text: #1a1a1a; /* Text color with --color-background in the background */ + --color-background-text-inactive: #666666; + --color-foreground: #881d1d; /* Primary foreground color (navbar, buttons, links) */ + --color-foreground-text: #ffffff; /* Text color with --color-foreground in the background */ + --color-foreground-hover: #9c2020; /* Active color for buttons/links on hover, with --color-foreground as the background */ + --color-foreground-active: #a92323; + --color-foreground-disabled: #e48181; + + --color-background-dividers: #dddddd; /* Colors to use for dividers and borders with --color-background in the background */ + + --color-background-panel: #fafafa; /* Panel background with --color-background in the background (used on footer, some popup panels) */ + --color-background-panel-active: #dddddd; /* Used when an element in a panel is active (such as hovering over a button) with --color-background-panel as the background */ + --color-background-panel-inactive: #bbbbbb;/* Text/divider/border color for faded/inactive elements, with --color-background-panel as the background */ + + --color-success: #009933; + --color-text-failure: #cc0000; + --color-text-warning: #ffcc00; + --size-mobile-switch: 1000px; +} \ No newline at end of file diff --git a/webroot/index.php b/webroot/index.php new file mode 100644 index 00000000..f3e76098 --- /dev/null +++ b/webroot/index.php @@ -0,0 +1,14 @@ + + +

+
+ +

This page is in progress. For now, visit our documentation for more info.

+ + diff --git a/webroot/js/ajax/ssh_generate.php b/webroot/js/ajax/ssh_generate.php new file mode 100644 index 00000000..c161079e --- /dev/null +++ b/webroot/js/ajax/ssh_generate.php @@ -0,0 +1,24 @@ +"; + +$rsa = new RSA(); +$rsa->setPublicKeyFormat(RSA::PUBLIC_FORMAT_OPENSSH); +if (isset($_GET["type"]) && $_GET["type"] == "ppk") { + $rsa->setPrivateKeyFormat(RSA::PRIVATE_FORMAT_PUTTY); // Set format to putty if requested +} +extract($rsa->createKey(2048)); + +echo "
"; +echo $publickey; +echo "
"; +echo "
"; +echo $privatekey; +echo "
"; + +echo ""; \ No newline at end of file diff --git a/webroot/js/eds/idpselect.css b/webroot/js/eds/idpselect.css new file mode 100644 index 00000000..80b340d9 --- /dev/null +++ b/webroot/js/eds/idpselect.css @@ -0,0 +1,195 @@ +/* Top level is idpSelectIdPSelector */ +#idpSelectIdPSelector +{ + width: 100%; + max-width: 400px; + text-align: left; + white-space: nowrap; +} + +/* Next down are the idpSelectPreferredIdPTile, idpSelectIdPEntryTile & idpSelectIdPListTile */ + +/** + * The preferred IdP tile (if present) has a specified height, so + * we can fit the preselected * IdPs in there + */ +#idpSelectPreferredIdPTile +{ + height:138px; /* Force the height so that the selector box + * goes below when there is only one preslect + */ +} +#idpSelectPreferredIdPTileNoImg +{ + height:60px; +} + +/*** + * The preselect buttons + */ +div.IdPSelectPreferredIdPButton +{ + margin: 3px; + width: 120px; /* Make absolute because 3 of these must fit inside + div.IdPSelect{width} with not much each side. */ + float: left; +} + +/* + * Make the entire box look like a hyperlink + */ +div.IdPSelectPreferredIdPButton a +{ + float: left; + width: 99%; /* Need a specified width otherwise we'll fit + the contents which we don't want because + they have auto margins */ + +} + +div.IdPSelectTextDiv{ + display: none; +} + +div.IdPSelectPreferredIdPImg +{ +/* max-width: 95%; */ + height: 69px; /* We need the absolute height to force all buttons to the same size */ + margin: 2px; +} + +img.IdPSelectIdPImg { + width:auto; +} + +div.IdPSelectautoDispatchTile { + display: block; +} + +div.IdPSelectPreferredIdPButton img +{ + display: block; /* Block display to allow auto centring */ + max-width: 114px; /* Specify max to allow scaling, percent does work */ + max-height: 64px; /* Specify max to allow scaling, percent doesn't work */ + margin-top: 3px ; + margin-bottom: 3px ; + border: solid 0px #000000; /* Strip any embellishments the brower may give us */ + margin-left: auto; /* Auto centring */ + margin-right: auto; /* Auto centring */ + +} + +div.IdPSelectPreferredIdPButton div.IdPSelectTextDiv +{ + text-align: center; + font-size: 12px; + font-weight: normal; + max-width: 95%; + height: 30px; /* Specify max height to allow two lines. The + * Javascript controlls the max length of the + * strings + */ +} + +/* + * Force the size of the selectors and the buttons + */ +#idpSelectInput, #idpSelectSelector +{ + width: calc(100% - 88px); + height: 31px; +} +/* + * For some reason a doesn't hence we have to force a margin onto the "; + if ($SERVICE->sql()->requestExists($USER->getUID())) { + echo ""; + echo ""; + } else { + echo ""; + } +} +?> + +
+ +
SSH Keys
+getSSHKeys(); // Get ssh public key attr +for ($i = 0; $sshPubKeys != null && $i < count($sshPubKeys); $i++) { // loop through keys + echo "
"; +} +?> + + + +
+ +Login Shell"; +echo "
"; +?> + +Cluster Access
"; +//echo ""; +?> + + + + + + \ No newline at end of file diff --git a/webroot/panel/ajax/get_group_members.php b/webroot/panel/ajax/get_group_members.php new file mode 100644 index 00000000..0547140c --- /dev/null +++ b/webroot/panel/ajax/get_group_members.php @@ -0,0 +1,38 @@ +getGroupMembers(); + +// verify that the user querying is actually in the group +$found = false; +foreach ($members as $member) { + if ($member->getUID() == $USER->getUID()) { + $found = true; + break; + } +} + +if (!$found) { + die(); +} + +$count = count($members); +foreach ($members as $key=>$member) { + if ($key >= $count - 1) { + echo ""; + } else { + echo ""; + } + + echo "" . $member->getFullname() . ""; + echo "" . $member->getUID() . ""; + echo "" . $member->getMail() . ""; + echo ""; + echo ""; +} \ No newline at end of file diff --git a/webroot/panel/groups.php b/webroot/panel/groups.php new file mode 100644 index 00000000..05532f35 --- /dev/null +++ b/webroot/panel/groups.php @@ -0,0 +1,173 @@ +getOwner(); + + switch ($_POST["form_name"]) { + case "addPIform": + // The new PI modal was submitted + // existing PI request + + if (!isset($_POST["pi"]) || empty($_POST["pi"])) { + // PI was not set + array_push($modalErrors, "You have not chosen a PI"); + } + + if (!$SERVICE->sacctmgr()->accountExists($_POST["pi"])) { + array_push($modalErrors, "This PI doesn't exist"); + } + + if ($SERVICE->sql()->requestExists($USER->getUID(), $_POST["pi"])) { + array_push($modalErrors, "You've already requested this"); + } + + // Add row to sql + if (empty($modalErrors)) { + $SERVICE->sql()->addRequest($USER->getUID(), $_POST["pi"]); + + // Send approval email to PI + $SERVICE->mail()->send("new_group_request", array("netid" => $USER->getUID(), "firstname" => $USER->getFirstname(), "lastname" => $USER->getLastname(), "mail" => $USER->getMail(), "to" => $pi_owner->getMail())); + } + + // 1. Check if PI value was submitted (DONE) + // 2. Check if submitted PI exists (DONE) + // 3. Check if PI request exists already (DONE) + // 4. Add row to sql table (DONE) + // 5. Send email to existing PI + break; + case "removePIForm": + // Remove PI form + if (!$SERVICE->sacctmgr()->accountExists($_POST["pi"])) { + break; + } + + if (!$SERVICE->sacctmgr()->userExists($USER->getUID(), $_POST["pi"])) { + break; + } + + $pi_user = new unityAccount($_POST["pi"], $SERVICE); + $pi_user->removeUserFromGroup($USER); + + $SERVICE->mail()->send("left_user", array("netid" => $USER->getUID(), "firstname" => $USER->getFirstname(), "lastname" => $USER->getLastname(), "mail" => $USER->getMail(), "to" => $pi_owner->getMail())); + + // 1. check if pi group exists (DONE) + // 1. Check the selected PI actually belongs to this user (DONE) + // 3. Remove slurm associations (DONE) + break; + } + } +} + +include config::PATHS["templates"] . "/header.php"; +?> + +

My Principal Investigators

+
+ +getGroups(); + +$requests = $SERVICE->sql()->getRequestsByUser($USER->getUID()); + +$req_filtered = array(); +foreach ($requests as $request) { + if ($request["request_for"] != "admin") { // put this in config later for gypsum + array_push($req_filtered, $request); + } +} + +if (count($groups) + count($req_filtered) == 0) { + echo "

You do not have any PIs attached to your account. You need at least one to use the cluster. Click the button below to request.

"; +} + +if (count($req_filtered) > 0) { + echo "

Pending Requests

"; + echo ""; + foreach ($req_filtered as $request) { + $requested_account = new unityAccount($request["request_for"], $SERVICE); + $requested_owner = $requested_account->getOwner(); + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + echo "
" . $requested_owner->getFirstname() . " " . $requested_owner->getLastname() . "" . $requested_account->getPIUID() . "" . $requested_owner->getMail() . "
"; + + if (count($groups) > 0) { + echo "
"; + } +} + + +echo ""; + +foreach ($groups as $group) { + $owner = $group->getOwner(); + + if ($USER->getUID() == $owner->getUID()) { + continue; + } + + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; +} + +echo "
"; +?> + + + + + + + + + + \ No newline at end of file diff --git a/webroot/panel/index.php b/webroot/panel/index.php new file mode 100644 index 00000000..5752b731 --- /dev/null +++ b/webroot/panel/index.php @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/webroot/panel/modal/new_key.php b/webroot/panel/modal/new_key.php new file mode 100644 index 00000000..bc7eba5c --- /dev/null +++ b/webroot/panel/modal/new_key.php @@ -0,0 +1,66 @@ + + +
+
+
+
+
+ +
+ + + +
+ + + + + + +
+ + \ No newline at end of file diff --git a/webroot/panel/modal/new_pi.php b/webroot/panel/modal/new_pi.php new file mode 100644 index 00000000..229ae7ec --- /dev/null +++ b/webroot/panel/modal/new_pi.php @@ -0,0 +1,43 @@ + + +
+ +
+ + +
+ +
+ + \ No newline at end of file diff --git a/webroot/panel/modal/pi_search.php b/webroot/panel/modal/pi_search.php new file mode 100644 index 00000000..e6ea4348 --- /dev/null +++ b/webroot/panel/modal/pi_search.php @@ -0,0 +1,27 @@ +No Results"); +} + +$sacctmgr = new slurm(config::CLUSTER["name"]); +$assocs = $sacctmgr->getAccounts(); + +$MAX_COUNT = 10; // Max results of PI search + +$out = array(); +foreach ($assocs as $assoc) { + // loop through each association + if (strpos($assoc, $search_query) !== false) { + array_push($out, $assoc); + if (count($out) >= $MAX_COUNT) { + break; + } + } +} + +foreach ($out as $pi_acct) { + echo "$pi_acct"; +} \ No newline at end of file diff --git a/webroot/panel/new_account.php b/webroot/panel/new_account.php new file mode 100644 index 00000000..d69cd784 --- /dev/null +++ b/webroot/panel/new_account.php @@ -0,0 +1,63 @@ +exists()) { + redirect("/panel/index.php"); // Redirect if account already exists +} + +if ($_SERVER["REQUEST_METHOD"] == "POST") { + $errors = array(); + + if (!isset($_POST["eula"]) || $_POST["eula"] != "agree") { + // checkbox was not checked + array_push($errors, "Accepting the EULA is required"); + } + + // Request Account Form was Submitted + if (count($errors) == 0) { + try { + $USER->init($SHIB["firstname"],$SHIB["lastname"],$SHIB["mail"]); + + redirect(config::PREFIX . "/panel"); + } catch (Exception $e) { + array_push($errors, unity_locale::ERR . "\n" . $e->getMessage()); + } + } +} + +?> + +

+
+ +
+ Please verify that the information below is correct before continuing +
+ Name  
+ Email   +
+ Your unity cluster username will be + +
+ + + + + + "; + foreach ($errors as $err) { + echo "" . $err . ""; + } + echo ""; + } + ?> +
+ + \ No newline at end of file diff --git a/webroot/panel/pi.php b/webroot/panel/pi.php new file mode 100644 index 00000000..d498f886 --- /dev/null +++ b/webroot/panel/pi.php @@ -0,0 +1,133 @@ +getAccount(); + +if (!$USER->isPI()) { + die(); +} + +if ($_SERVER["REQUEST_METHOD"] == "POST") { + + if (isset($_POST["uid"])) { + $form_user = new unityUser($_POST["uid"], $SERVICE); + } + + switch ($_POST["form_name"]) { + case "approveReq": + // approve request button clicked + + $group->addUserToGroup($form_user); // Add to group (ldap and slurm) + + $group->removeRequest($form_user->getUID()); // remove request from db + + // Send approval email to admins + $SERVICE->mail()->send("join_pi", array("to" => $form_user->getMail(), "group" => $group->getPIUID())); + + // (1) Create slurm association [DONE] + // (2) Remove SQL Row if (1) succeeded [DONE] + // (3) Send email to requestor + break; + case "denyReq": + // deny request button clicked + + $group = $USER->getAccount(); + + $group->removeRequest($form_user->getUID()); // remove request from db + + $SERVICE->mail()->send("deny_pi", array("to" => $form_user->getMail(), "group" => $group->getPIUID())); + + // (1) Remove SQL Row + // (2) Send email to requestor + break; + case "remUser": + // remove user button clicked + + $group = $USER->getAccount(); + + $group->removeUserFromGroup($form_user); + + $SERVICE->mail()->send("rem_pi", array("to" => $form_user->getMail(), "group" => $group->getPIUID())); + + // (1) Remove slurm association + // (2) Send email to removed user + break; + } +} + +include config::PATHS["templates"] . "/header.php"; +?> + +

My Users

+
+ +getRequests(); +$assocs = $group->getGroupMembers(); + +if (count($requests) + count($assocs) == 0) { + echo "

You do not have any users attached to your PI account. Ask your users to request to join your account on the My PIs page.

"; +} else { + echo "

The following users are attached to your PI group and are authorized to use Unity

"; +} + +if (count($requests) > 0) { + echo "

Pending Requests

"; + echo ""; + + foreach ($requests as $request) { + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + } + echo "
" . $request->getFirstname() . " " . $request->getLastname() . "" . $request->getUID() . "" . $request->getMail() . ""; + echo "
"; + echo "
"; + echo "
"; + + if (count($assocs) > 0) { + echo "
"; + } +} + +echo ""; + +foreach ($assocs as $assoc) { + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; + echo ""; +} + +echo "
" . $assoc->getFirstname() . " " . $assoc->getLastname() . "" . $assoc->getUID() . "" . $assoc->getMail() . ""; + echo "
"; + echo "
"; +?> + + + + + \ No newline at end of file diff --git a/webroot/panel/support.php b/webroot/panel/support.php new file mode 100644 index 00000000..c9bb87e0 --- /dev/null +++ b/webroot/panel/support.php @@ -0,0 +1,38 @@ + + +

Support

+
+ +
+ +

How do I connect to, and start using the cluster?

+

Refer to connection instructions on our documentation page here. You can connect over SSH or JupyterLab.

+ +

When I connect over SSH I get a message saying "permission denied (public key)"

+

This can be due to a few reasons:

+
    +
  • You have not provided your public key while connecting. Using
    ssh -i [private_key_location] [user]@unity.rc.umass.edu should help.
  • +
  • You are not assigned to at least 1 PI group. We require at least 1 PI to endorse your account before you can use the cluster. Request to join a PI on the My PIs page.
  • +
  • You have not added a public key to your account on Unity yet. You can do this on the Account Settings page.
  • +
+ +

Where can I find software to use on the cluster?

+

Most of our software is package installed and is available by default. Many other software which have different versions are available as modules. The command

module av
will print all available modules. Then you can use
module load [name]
to load a module and have access to its binaries.

+ +

How much storage do I get on Unity and is it backed up?

+

Your home directory

/home/[user]
has 500GB of storage which can be expanded on request. Your scratch space
/scratch/[user]
has unlimited storage, but inactive files beyond 90 days will be auto-deleted without warning.

+

We do not provide backup solutions by default. We take a snapshot of all storage at 1 AM every day for the past 48 hours. This way, if you accidentally deleted something it wouldn't be difficult to get it back within that time frame.

+ +

Any more questions, bug reports, software requests, etc.? Email us at hpc@umass.edu.

+ +
+ + \ No newline at end of file diff --git a/webroot/priv.php b/webroot/priv.php new file mode 100644 index 00000000..6c532bf4 --- /dev/null +++ b/webroot/priv.php @@ -0,0 +1,26 @@ + + +

+ + +

By using resources associated with Unity, you agree to comply with the following conditions of use. This is an extension of the University of Massachussetts Amherst Information Technology Acceptable Use Policy, which can be found here.

+ +
    +
  1. You will not use Unity resources for illicit financial gain, such as virtual currency mining, or any unlawful purpose, nor attempt to breach or circumvent any Unity administrative or security controls. You will comply with all applicable laws, working with your home institution and the specific Unity service providers utilized to determine what constraints may be placed on you by any relevant regulations such as export control law or HIPAA.
  2. +
  3. You will respect intellectual property rights and observe confidentiality agreements.
  4. +
  5. You will protect the access credentials (e.g., passwords, private keys, and/or tokens) issued to you or generated to access Unity resources; these are issued to you for your sole use.
  6. +
  7. You will immediately report any known or suspected security breach or loss or misuse of Unity access credentials to hpc@it.umass.edu.
  8. +
  9. You will have only one Unity User account and will keep your profile information up-to-date.
  10. +
  11. Use of resources and services through Unity is at your own risk. There are no guarantees that resources and services will be available, that they will suit every purpose, or that data will never be lost or corrupted. Users are responsible for backing up critical data.
  12. +
  13. Logged information, including information provided by you for registration purposes, is used for administrative, operational, accounting, monitoring and security purposes. This information may be disclosed, via secured mechanisms, only for the same purposes and only as far as necessary to other organizations cooperating with Unity .
  14. +
+ +

The Unity team reserves the right to restrict access to any individual/group found to be in breach of the above.

+ + diff --git a/webroot/res/logo.png b/webroot/res/logo.png new file mode 100644 index 00000000..c27bd0ca Binary files /dev/null and b/webroot/res/logo.png differ diff --git a/webroot/res/menu.png b/webroot/res/menu.png new file mode 100644 index 00000000..6f77b22f Binary files /dev/null and b/webroot/res/menu.png differ diff --git a/webroot/res/mghpcc-image.png b/webroot/res/mghpcc-image.png new file mode 100644 index 00000000..99c4be7d Binary files /dev/null and b/webroot/res/mghpcc-image.png differ diff --git a/webroot/res/mghpcc.png b/webroot/res/mghpcc.png new file mode 100644 index 00000000..ba4d4774 Binary files /dev/null and b/webroot/res/mghpcc.png differ diff --git a/webroot/res/umass.png b/webroot/res/umass.png new file mode 100644 index 00000000..cf5982d5 Binary files /dev/null and b/webroot/res/umass.png differ