├── src ├── server │ ├── lib │ │ ├── __init__.py │ │ ├── constants.py │ │ └── tools.py │ ├── web │ │ ├── static │ │ │ ├── images │ │ │ │ └── logo.png │ │ │ └── css │ │ │ │ ├── reset.css │ │ │ │ ├── main.css │ │ │ │ └── typography.css │ │ ├── requirements.txt │ │ ├── templates │ │ │ ├── 403.html │ │ │ ├── 401.html │ │ │ ├── 404.html │ │ │ ├── add.html │ │ │ ├── sign.html │ │ │ ├── status.html │ │ │ ├── homepage.html │ │ │ └── base.html │ │ ├── Dockerfile │ │ ├── settings.txt.sample │ │ ├── INSTALL.md │ │ ├── CHANGELOG.md │ │ └── cassh_web.py │ ├── requirements.txt │ ├── sql │ │ ├── revocation.sql │ │ └── users.sql │ ├── cassh.service │ ├── conf │ │ └── cassh.conf │ ├── Dockerfile │ ├── docker-entrypoint │ ├── INSTALL.md │ ├── ssh_utils │ │ └── __init__.py │ ├── CHANGELOG.md │ └── server.py └── client │ ├── requirements.txt │ ├── INSTALL.md │ ├── cassh_docker.sh │ ├── cassh-client.conf │ ├── Dockerfile │ ├── CHANGELOG.md │ └── cassh ├── tests ├── requirements.txt ├── cassh │ ├── update_hosts.sh │ ├── ldap_mapping.json.sample │ └── cassh.conf.sample ├── test_cluster.sh ├── openldap │ ├── ca.crt │ └── add-users.ldif ├── postgres │ ├── clean_pg.py │ └── init_pg.py ├── test_admin_delete.sh ├── test_admin_set.sh ├── test_client_sign_error.sh ├── launch_demo_server.sh ├── test.sh ├── test_krl.sh ├── test_principals_search.sh ├── test_client_add.sh ├── test_admin_activate.sh └── test_principals.sh ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── README.md └── LICENSE /src/server/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg2-binary 2 | -------------------------------------------------------------------------------- /src/client/requirements.txt: -------------------------------------------------------------------------------- 1 | configparser 2 | requests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test-keys/ 2 | ssl/ 3 | *.pyc 4 | tests/cassh/*.conf 5 | tests/cassh/*.json 6 | settings.txt 7 | venv 8 | -------------------------------------------------------------------------------- /src/server/web/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nbeguier/cassh/HEAD/src/server/web/static/images/logo.png -------------------------------------------------------------------------------- /tests/cassh/update_hosts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | IP=$1 4 | HOSTNAME=$2 5 | 6 | echo "${IP} ${HOSTNAME}" >> /etc/hosts 7 | -------------------------------------------------------------------------------- /src/server/web/requirements.txt: -------------------------------------------------------------------------------- 1 | flask>=3.1.0 2 | markdown>=3.8 3 | pyopenssl>=25.0.0 4 | pyyaml>=6.0.2 5 | requests>=2.32.3 6 | waitress>=3.0.2 7 | -------------------------------------------------------------------------------- /src/server/requirements.txt: -------------------------------------------------------------------------------- 1 | configparser>=7.2.0 2 | psycopg2-binary>=2.9.10 3 | python-ldap>=3.4.4 4 | requests>=2.32.3 5 | web.py>=0.62 6 | wheel>=0.45.1 7 | -------------------------------------------------------------------------------- /tests/cassh/ldap_mapping.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "guest,dc=example,dc=org": ["guest-everywhere"], 3 | "admin,dc=example,dc=org": ["root-everywhere", "guest-everywhere"] 4 | } 5 | -------------------------------------------------------------------------------- /src/server/web/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}403 Forbidden{% endblock %} 4 | 5 | {% block content %} 6 |
| Login | {{ result.realname }} |
|---|---|
| Status | {{ result.status }} |
| Expiration | {{ result.expiration }} |
| SSH Key Hash | {{ result.ssh_key_hash }} |
| Username | {{ result.username }} |
| Expiry | {{ result.expiry }} |
| Principals | {{ result.principals }} |
,. */ 41 | blockquote:before, blockquote:after, q:before, q:after { content: ""; } 42 | blockquote, q { quotes: "" ""; } 43 | 44 | /* Remove annoying border on linked images. */ 45 | a img { border: none; } 46 | -------------------------------------------------------------------------------- /src/server/docker-entrypoint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | 4 | # == Variables 5 | # 6 | # Overriddable by ENV variables 7 | CASSH_CONF_DIR="${CASSH_CONF_DIR:-/opt/cassh/server/conf}" 8 | CASSH_KEYS_DIR="${CASSH_KEYS_DIR:-/opt/cassh/server/keys}" 9 | 10 | 11 | # == Shell optiosn to fail fast 12 | # 13 | set -o errexit 14 | set -o nounset 15 | set -o pipefail 16 | 17 | 18 | # == Run 19 | # 20 | echo "===> START" 21 | 22 | echo "---> Testing CASSH-server SSH keys Directory" 23 | if [[ -d "${CASSH_KEYS_DIR}" ]]; then 24 | echo " * Directory 'CASSH_KEYS_DIR=${CASSH_KEYS_DIR}' already exists" 25 | 26 | else 27 | echo " * Creating 'CASSH_KEYS_DIR=${CASSH_KEYS_DIR}'" 28 | mkdir -p "${CASSH_KEYS_DIR}" 29 | fi 30 | 31 | echo "---> Testing CASSH-server SSH keys" 32 | if [[ -e "${CASSH_KEYS_DIR}/id_rsa_ca" && -e "${CASSH_KEYS_DIR}/revoked-keys" ]]; then 33 | echo " * SSH Keys already exist, skipping SSH Keys creations" 34 | 35 | else 36 | echo " * Creating CASSH-server SSH Keys in 'CASSH_KEYS_DIR=${CASSH_KEYS_DIR}'" 37 | ssh-keygen -t rsa -b 4096 -o -a 100 -N "" -f "${CASSH_KEYS_DIR}/id_rsa_ca" 38 | ssh-keygen -k -f "${CASSH_KEYS_DIR}/revoked-keys" 39 | fi 40 | 41 | 42 | echo "---> Starting CASSH-server" 43 | echo " * Using CASSH_CONF_DIR=${CASSH_CONF_DIR} (Config file 'cassh.conf')" 44 | echo " * Using CASSH_KEYS_DIR=${CASSH_KEYS_DIR}" 45 | 46 | 47 | set -x 48 | exec python /opt/cassh/server/server.py --config "${CASSH_CONF_DIR}/cassh.conf" -------------------------------------------------------------------------------- /tests/postgres/init_pg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Init pg database 5 | """ 6 | 7 | from os import listdir 8 | from os.path import isfile, join 9 | import sys 10 | 11 | # Third party library imports 12 | from psycopg2 import connect, OperationalError 13 | 14 | # DEBUG 15 | # from pdb import set_trace as st 16 | 17 | SQL_SERVER_PATH = 'src/server/sql' 18 | 19 | def pg_connection(dbname='postgres', user='postgres', host='localhost',\ 20 | password='mysecretpassword'): 21 | """ 22 | Return a connection to the db 23 | """ 24 | try: 25 | pg_conn = connect("dbname='%s' user='%s' host='%s' password='%s'"\ 26 | % (dbname, user, host, password)) 27 | except OperationalError: 28 | print('I am unable to connect to the database') 29 | pg_conn = None 30 | return pg_conn 31 | 32 | def init_pg(pg_conn): 33 | """ 34 | Initialize pg database 35 | """ 36 | if pg_conn is None: 37 | print('I am unable to connect to the database') 38 | sys.exit(1) 39 | cur = pg_conn.cursor() 40 | 41 | sql_files = [f for f in listdir(SQL_SERVER_PATH) if isfile(join(SQL_SERVER_PATH, f))] 42 | 43 | for sql_file in sql_files: 44 | with open('%s/%s' % (SQL_SERVER_PATH, sql_file), 'r') as sql_model_file: 45 | cur.execute(sql_model_file.read()) 46 | pg_conn.commit() 47 | 48 | cur.close() 49 | pg_conn.close() 50 | 51 | 52 | if __name__ == "__main__": 53 | init_pg(pg_connection()) 54 | -------------------------------------------------------------------------------- /src/server/INSTALL.md: -------------------------------------------------------------------------------- 1 | # INSTALL - Cassh Server 2 | 3 | ## From scratch 4 | 5 | ### Prerequisites 6 | 7 | ```bash 8 | # Install cassh python 3 service dependencies 9 | sudo apt install openssh-client openssl libldap2-dev libsasl2-dev build-essential python3-dev 10 | sudo apt install python3-pip 11 | pip install -U pip 12 | pip install -r requirements.txt 13 | 14 | # Generate CA ssh key and revocation key file 15 | mkdir test-keys 16 | ssh-keygen -C CA -t rsa -b 4096 -o -a 100 -N "" -f test-keys/id_rsa_ca # without passphrase 17 | ssh-keygen -k -f test-keys/revoked-keys 18 | ``` 19 | 20 | ### Server : Database 21 | 22 | * You need a database and a user's credentials 23 | * Init the database with this sql statement: [SQL Model](sql/model.sql) 24 | * Update the `cassh-server` config with the user's credentials 25 | 26 | ## Optionnal features 27 | 28 | ### Active SSL 29 | ```ini 30 | [ssl] 31 | private_key = __CASSH_PATH__/ssl/server.key 32 | public_key = __CASSH_PATH__/ssl/server.pem 33 | ``` 34 | 35 | ### Active LDAP 36 | ```ini 37 | [ldap] 38 | host = ldap.example.org 39 | # ldap procotol can be: ldap, ldaps or starttls 40 | protocol = ldap 41 | bind_dn = dc=example,dc=org 42 | username = cn=cassh,dc=example,dc=org 43 | password = mypassword 44 | admin_cn = cn=admin,dc=example,dc=org 45 | # LDAP key to match realname 46 | filter_realname_key = userPrincipalName 47 | # LDAP key to match admin_cn 48 | filter_memberof_key = memberOf 49 | # Optionnal: 50 | # username_prefix = cn= 51 | # username_suffix = ,dc=example,dc=org 52 | ``` 53 | -------------------------------------------------------------------------------- /src/server/web/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |CASSH : {% block title %}{% endblock %} 6 | 7 | 8 | 9 | 10 | 11 |12 | 16 | 17 | 25 | 26 |30 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /tests/openldap/add-users.ldif: -------------------------------------------------------------------------------- 1 | dn: cn=sysadmin@example.org,dc=example,dc=org 2 | uid: sysadmin@example.org 3 | cn: sysadmin@example.org 4 | sn: 3 5 | objectClass: top 6 | objectClass: posixAccount 7 | objectClass: inetOrgPerson 8 | loginShell: /bin/bash 9 | homeDirectory: /root 10 | uidNumber: 14583102 11 | gidNumber: 14564100 12 | # password: b@dt€xt 13 | userPassword: {SSHA}GA//Zq4zBobJyTM9CaNsSMZimTYAvxuc 14 | mail: sysadmin@example.org 15 | # memberOf 16 | gecos: admin,dc=example,dc=org 17 | 18 | dn: cn=guest.a@example.org,dc=example,dc=org 19 | uid: guest.a@example.org 20 | cn: guest.a@example.org 21 | sn: 4 22 | objectClass: top 23 | objectClass: posixAccount 24 | objectClass: inetOrgPerson 25 | loginShell: /bin/bash 26 | homeDirectory: /home/guest 27 | uidNumber: 14583103 28 | gidNumber: 14564100 29 | # password: hG9`P:JznneDcTfN}6KLr-V^v$EKR27 | {% block content %}{% endblock %} 28 |29 |/tmp/test-cert 51 | if ssh-keygen -L -f /tmp/test-cert >/dev/null 2>&1; then 52 | echo "[OK] Test signing key when changing expiry" 53 | else 54 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key when changing expiry : ${RESP}" 55 | fi 56 | rm -f /tmp/test-cert 57 | -------------------------------------------------------------------------------- /tests/test_client_sign_error.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # shellcheck disable=SC2128 3 | 4 | RESP=$(curl -s -X POST "${CASSH_SERVER_URL}"/client) 5 | if [ "${RESP}" == 'Error: No realname option given.' ]; then 6 | echo "[OK] Test signing key without username,realname,password" 7 | else 8 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key without username,realname,password: ${RESP}" 9 | fi 10 | 11 | RESP=$(curl -s -X POST -d "realname=${GUEST_A_REALNAME}" "${CASSH_SERVER_URL}"/client) 12 | if [ "${RESP}" == 'Error: No password option given.' ]; then 13 | echo "[OK] Test signing key without username,password" 14 | else 15 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key without username,password: ${RESP}" 16 | fi 17 | 18 | RESP=$(curl -s -X POST -d "realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/client) 19 | if [ "${RESP}" == 'Error: No username option given.' ]; then 20 | echo "[OK] Test signing key without username" 21 | else 22 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key without username: ${RESP}" 23 | fi 24 | 25 | RESP=$(curl -s -X POST -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/client) 26 | if [ "${RESP}" == 'Error: No pubkey given.' ]; then 27 | echo "[OK] Test signing key with no pubkey" 28 | else 29 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key with no pubkey : ${RESP}" 30 | fi 31 | 32 | RESP=$(curl -s -X POST -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}&pubkey=bad_pubkey" "${CASSH_SERVER_URL}"/client) 33 | if [ "${RESP}" == 'Error : Public key unprocessable' ]; then 34 | echo "[OK] Test signing key with bad pubkey" 35 | else 36 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key with bad pubkey : ${RESP}" 37 | fi 38 | 39 | RESP=$(curl -s -X POST -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}&pubkey=${GUEST_B_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 40 | if [ "${RESP}" == 'Error : (username, realname, pubkey) triple mismatch.' ]; then 41 | echo "[OK] Test signing key when wrong public key" 42 | else 43 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key when wrong public key : ${RESP}" 44 | fi 45 | 46 | RESP=$(curl -s -X POST -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 47 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 48 | echo "[OK] Test signing key with invalid credentials" 49 | else 50 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key when PENDING status : ${RESP}" 51 | fi 52 | 53 | RESP=$(curl -s -X POST -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 54 | if [ "${RESP}" == "Status: PENDING" ]; then 55 | echo "[OK] Test signing key when PENDING status" 56 | else 57 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key when PENDING status : ${RESP}" 58 | fi 59 | -------------------------------------------------------------------------------- /tests/launch_demo_server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | CARRY_POSTGRES=false 4 | ENTRYPOINT= 5 | MOUNT_VOL= 6 | PORT=8080 7 | 8 | function usage() 9 | { 10 | echo "Usage:" 11 | echo "$0 [-d|--debug] [-h|---help] [-p|--port ] [-s|--server_code_path ]" 12 | echo "" 13 | exit 0 14 | } 15 | 16 | POSITIONAL=() 17 | while [[ $# -gt 0 ]] 18 | do 19 | key="$1" 20 | 21 | case $key in 22 | -s|--server_code_path) 23 | SERVER_CODE_PATH="$2" 24 | if ! [ -d "/${SERVER_CODE_PATH}" ]; then 25 | echo "Server code path /${SERVER_CODE_PATH} doesn't exist." 26 | exit 1 27 | fi 28 | MOUNT_VOL="-v ${SERVER_CODE_PATH}:/opt/cassh/" 29 | shift # past argument 30 | shift # past value 31 | ;; 32 | -p|--port) 33 | PORT="$2" 34 | shift # past argument 35 | shift # past value 36 | ;; 37 | -d|--debug) 38 | ENTRYPOINT='--entrypoint /bin/bash' 39 | shift # past argument 40 | ;; 41 | -h|--help) 42 | usage 43 | ;; 44 | *) # unknown option 45 | POSITIONAL+=("$1") # save it in an array for later 46 | shift # past argument 47 | ;; 48 | esac 49 | done 50 | set -- "${POSITIONAL[@]}" # restore positional parameters 51 | 52 | echo 'INIT : CASSH Server' 53 | 54 | if [ ! "$(docker ps -q -f name=demo-postgres)" ]; then 55 | CARRY_POSTGRES=true 56 | 57 | echo 'Starting Postgresql server' 58 | docker run --rm -p 5432:5432 --name demo-postgres -e POSTGRES_PASSWORD=mysecretpassword postgres:latest & 59 | 60 | sleep 10 61 | 62 | echo "Initialize Postgresql server" 63 | python3 tests/postgres/init_pg.py 64 | 65 | sleep 5 66 | fi 67 | 68 | echo 'Starting OpenLDAP server' 69 | docker run --rm -d --hostname demo-openldap --env LDAP_TLS_VERIFY_CLIENT=try -v ${PWD}/tests/openldap/:/tmp/openldap/ -p 389:389 -p 636:636 --name demo-openldap osixia/openldap:1.5.0 70 | sleep 3 71 | echo 'Initialize OpenLDAP server' 72 | docker exec demo-openldap ldapadd -x -f /tmp/openldap/add-users.ldif -D "cn=admin,dc=example,dc=org" -w admin 73 | 74 | 75 | echo 'Starting CA-SSH demo server' 76 | docker run -it -d --rm -p "${PORT}":8080 ${MOUNT_VOL} ${ENTRYPOINT} --env LDAPTLS_CACERT=/opt/cassh/tests/openldap/ca.crt --name demo-cassh nbeguier/cassh-server:latest 77 | # Update /etc/hosts 78 | docker exec demo-cassh /opt/cassh/tests/cassh/update_hosts.sh $(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' demo-postgres) demo-postgres 79 | docker exec demo-cassh /opt/cassh/tests/cassh/update_hosts.sh $(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' demo-openldap) demo-openldap 80 | 81 | echo "POSTGRESQL IP: $(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' demo-postgres)" 82 | echo "OPENLDAP IP: $(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' demo-openldap)" 83 | echo "CASSH IP: $(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' demo-cassh)" 84 | echo '' 85 | echo '> /opt/cassh/src/server/server.py --config /opt/cassh/tests/cassh/cassh.conf' 86 | 87 | docker attach demo-cassh 88 | 89 | if $CARRY_POSTGRES; then 90 | echo 'Stoping Postgresql server' 91 | docker stop demo-postgres 92 | fi 93 | docker stop demo-openldap 94 | -------------------------------------------------------------------------------- /tests/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # shellcheck disable=SC2128 3 | # shellcheck disable=SC2034 4 | # shellcheck disable=SC2016 5 | 6 | CASSH_SERVER_URL=${1:-http://localhost:8080} 7 | 8 | KEY_1_EXAMPLE=/tmp/.id_rsa 9 | KEY_2_EXAMPLE=/tmp/.id_ecdsa 10 | KEY_3_EXAMPLE=/tmp/.id_rsa3 11 | KEY_4_EXAMPLE=/tmp/.id_rsa4 12 | 13 | # Generate random keys 14 | echo -e 'y\n' | ssh-keygen -t rsa -b 4096 -o -a 100 -f "${KEY_1_EXAMPLE}" -q -N "" >/dev/null 2>&1 15 | echo -e 'y\n' | ssh-keygen -t ecdsa -b 521 -f "${KEY_2_EXAMPLE}" -q -N "" >/dev/null 2>&1 16 | echo -e 'y\n' | ssh-keygen -t rsa -b 2048 -o -a 100 -f "${KEY_3_EXAMPLE}" -q -N "" >/dev/null 2>&1 17 | echo -e 'y\n' | ssh-keygen -t rsa -b 1024 -o -a 100 -f "${KEY_4_EXAMPLE}" -q -N "" >/dev/null 2>&1 18 | 19 | # USER: sysadmin 20 | SYSADMIN_PUB_KEY=$(cat "${KEY_1_EXAMPLE}".pub) 21 | SYSADMIN_USERNAME=sysadmin$(pwgen -A -0 10) 22 | SYSADMIN_REALNAME=sysadmin@example.org 23 | SYSADMIN_PASSWORD=b@dt€xt 24 | 25 | # USER: guest.a 26 | GUEST_A_PUB_KEY=$(cat "${KEY_2_EXAMPLE}".pub) 27 | GUEST_A_USERNAME=guesta$(pwgen -A -0 10) 28 | GUEST_A_REALNAME=guest.a@example.org 29 | GUEST_A_PASSWORD=hG9%60P%3AJznneDcTfN%7D6KLr%2DV%5Ev%24EKR%3CDrx%22qj%28%5C%2Bf # urlencoded 30 | 31 | # USER: guest.b 32 | GUEST_B_PUB_KEY=$(cat "${KEY_3_EXAMPLE}".pub) 33 | GUEST_B_ALT_PUB_KEY=$(cat "${KEY_3_EXAMPLE}".pub | awk '{print $1" "$2" some-random-comment"}') 34 | GUEST_B_USERNAME=guestb$(pwgen -A -0 10) 35 | GUEST_B_REALNAME=guest.b@example.org 36 | GUEST_B_PASSWORD=ohwie6aegohghaegho2zed2kah6gaajeiV0ThahQu6oogukaevei4eeh9co0aiyeem9baeKeeh1ohphae9ies0ahx0Eechuij4osaej5ahchei1Jo2gaze2ahch3ohpiyie4hai4ohdi0fohx2akae4ooChohce1Thieg4shoosh9epae9ainooy1uepaad1gei1pheongaunie0mohy3Ich9eetohn1ni9johzaiMoan8sha7eish6Gee 37 | 38 | # USER: guest.c, same realname as guest.b 39 | GUEST_C_PUB_KEY=$(cat "${KEY_4_EXAMPLE}".pub) 40 | GUEST_C_USERNAME=guestc$(pwgen -A -0 10) 41 | GUEST_C_REALNAME=guest.b@example.org 42 | GUEST_C_PASSWORD=ohwie6aegohghaegho2zed2kah6gaajeiV0ThahQu6oogukaevei4eeh9co0aiyeem9baeKeeh1ohphae9ies0ahx0Eechuij4osaej5ahchei1Jo2gaze2ahch3ohpiyie4hai4ohdi0fohx2akae4ooChohce1Thieg4shoosh9epae9ainooy1uepaad1gei1pheongaunie0mohy3Ich9eetohn1ni9johzaiMoan8sha7eish6Gee 43 | 44 | BADTEXT=b@dt€xt 45 | 46 | # Clean postgresql database 47 | ./tests/postgres/clean_pg.py 48 | 49 | RESP=$(curl -s "${CASSH_SERVER_URL}"/ping) 50 | if [ "${RESP}" == 'pong' ]; then 51 | echo "[OK] Test ping" 52 | else 53 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test ping : ${RESP}" 54 | fi 55 | 56 | curl -s "${CASSH_SERVER_URL}"/health >/dev/null 2>&1 57 | if [ $? -eq 0 ]; then 58 | echo "[OK] Test health" 59 | else 60 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test health" 61 | fi 62 | 63 | RESP=$(curl -s -X POST -d "realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/client/status) 64 | if [ "${RESP}" == 'None' ]; then 65 | echo "[OK] Test status unknown user" 66 | else 67 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test status unknown user : ${RESP}" 68 | fi 69 | 70 | . ./tests/test_client_add.sh 71 | . ./tests/test_client_sign_error.sh 72 | . ./tests/test_admin_activate.sh 73 | . ./tests/test_principals.sh 74 | . ./tests/test_principals_search.sh 75 | . ./tests/test_admin_set.sh 76 | . ./tests/test_admin_delete.sh 77 | . ./tests/test_cluster.sh 78 | -------------------------------------------------------------------------------- /src/server/web/static/css/main.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 101%; 3 | } 4 | 5 | body { 6 | background-color: #8bd8ad; 7 | } 8 | 9 | div#wrapper { 10 | background-color: white; 11 | width: 700px; 12 | margin: 30px auto 10px auto; 13 | padding-bottom: 50px; 14 | overflow: auto; 15 | min-height: 400px; 16 | -webkit-border-radius: 20px; 17 | -moz-border-radius: 20px; 18 | } 19 | 20 | div#header { 21 | width: 100%; 22 | overflow: auto; 23 | } 24 | 25 | div#header img { 26 | display: block; 27 | float: left; 28 | margin: 10px; 29 | } 30 | 31 | h1, h2, h3, h4 { 32 | font-weight: bold; 33 | text-shadow: 0 1px 1px #CCC; 34 | } 35 | 36 | h2, h3, h4 { 37 | border-bottom: 1px solid #DDD; 38 | } 39 | 40 | h4 { 41 | margin-bottom: 0.25em; 42 | } 43 | 44 | h2 { 45 | margin-top: 20px; 46 | } 47 | 48 | ul { 49 | margin: 0; 50 | } 51 | 52 | div.meeting ul { 53 | margin-bottom: 1.5em; 54 | } 55 | 56 | div#header h1 { 57 | margin-top: 60px; 58 | font-weight: bold; 59 | float: left; 60 | } 61 | 62 | div#content { 63 | width: 460px; 64 | float: left; 65 | } 66 | 67 | div#menu { 68 | width: 130px; 69 | margin-left: 40px; 70 | float: left; 71 | font-size: 150%; 72 | } 73 | 74 | div#menu ul { 75 | list-style-type: none; 76 | margin: 0; 77 | padding: 0; 78 | } 79 | 80 | div#menu ul li a { 81 | color: #404040; 82 | font-weight: bold; 83 | text-decoration: none; 84 | display: block; 85 | } 86 | 87 | div#menu ul li a:hover { 88 | color: #333; 89 | text-shadow: 0 1px 1px #AAA; 90 | } 91 | 92 | div#menu li.meetings a { 93 | display: block; 94 | float: left; 95 | } 96 | 97 | div#menu li.meetings a.feed { 98 | margin-left: 5px; 99 | margin-top: 1px; 100 | } 101 | 102 | div#footer { 103 | width: 700px; 104 | margin: 0 auto; 105 | text-align: center; 106 | } 107 | 108 | div.vevent { 109 | margin-bottom: 20px; 110 | border-left: 1px dotted #000; 111 | padding-left: 10px; 112 | } 113 | 114 | div.vevent abbr { 115 | border-bottom: none; 116 | } 117 | 118 | div.vevent p { 119 | margin-bottom: 0.25em; 120 | } 121 | 122 | div.vevent p.summary { 123 | border-bottom: 0; 124 | } 125 | 126 | div.vevent p.summary { 127 | font-size: 120%; 128 | font-weight: bold; 129 | } 130 | 131 | div.meeting h2 { 132 | margin-bottom: 0; 133 | } 134 | 135 | div.past_meeting, div.alert { 136 | background-color: #99FF99; 137 | width: 440px; 138 | padding: 10px; 139 | margin: 10px 0; 140 | } 141 | 142 | div.past_meeting h4, div.alert h4 { 143 | border: none; 144 | } 145 | 146 | div.past_meeting p:last-child { 147 | margin-bottom: 0; 148 | } 149 | 150 | div.elsewhere h3 { 151 | border: 0; 152 | } 153 | 154 | div.twtr-widget h1, 155 | div.twtr-widget h2, 156 | div.twtr-widget h3, 157 | div.twtr-widget h4, 158 | div.twtr-widget h5 { 159 | border: 0; 160 | } 161 | 162 | #twitter { 163 | width: 225px; 164 | float: left; 165 | padding-right: 5px; 166 | border-right: 1px solid #DDD; 167 | } 168 | 169 | #googlegroup { 170 | width: 220px; 171 | float: left; 172 | margin-left: 5px; 173 | } 174 | 175 | #googlegroup h4 { 176 | border-bottom: 0; 177 | } 178 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at nicolas_beguier@hotmail.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /tests/test_krl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # shellcheck disable=SC2128 3 | 4 | CASSH_SERVER_URL=${1:-http://localhost:8080} 5 | CASSH_SERVER_2_URL=${2:-http://localhost:8081} 6 | 7 | KEY_1_EXAMPLE=/tmp/.id_rsa 8 | KEY_2_EXAMPLE=/tmp/.id_ecdsa 9 | USER1=$(pwgen -A -0 10) 10 | USER2=$(pwgen -A -0 10) 11 | 12 | # Generate random keys 13 | echo -e 'y\n' | ssh-keygen -t rsa -b 4096 -o -a 100 -f "${KEY_1_EXAMPLE}" -q -N "" >/dev/null 2>&1 14 | echo -e 'y\n' | ssh-keygen -t ecdsa -b 521 -f "${KEY_2_EXAMPLE}" -q -N "" >/dev/null 2>&1 15 | 16 | PUB_KEY_1_EXAMPLE=$(cat "${KEY_1_EXAMPLE}".pub) 17 | PUB_KEY_2_EXAMPLE=$(cat "${KEY_2_EXAMPLE}".pub) 18 | 19 | # Get the latest krl 20 | rm -f /tmp/.revoked-keys 21 | curl -s "${CASSH_SERVER_URL}"/krl -o /tmp/.revoked-keys 22 | 23 | # Check if USER1 or USER2 is revoked 24 | RESP_1=$(ssh-keygen -Q -f /tmp/.revoked-keys "${KEY_1_EXAMPLE}".pub | awk '{print $NF}') 25 | if [ "${RESP_1}" == 'ok' ]; then 26 | echo "[OK] Test krl for non-revoked key" 27 | else 28 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test krl for non-revoked key : ${RESP_1}" 29 | fi 30 | RESP_2=$(ssh-keygen -Q -f /tmp/.revoked-keys "${KEY_2_EXAMPLE}".pub | awk '{print $NF}') 31 | if [ "${RESP_2}" == 'ok' ]; then 32 | echo "[OK] Test krl for non-revoked key" 33 | else 34 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test krl for non-revoked key : ${RESP_2}" 35 | fi 36 | 37 | 38 | # Create USER1 39 | RESP=$(curl -s -X PUT -d "username=${USER1}&realname=${USER1}@domain.fr&pubkey=${PUB_KEY_1_EXAMPLE}" "${CASSH_SERVER_URL}"/client) 40 | if [ "${RESP}" == "Create user=${USER1}. Pending request." ]; then 41 | echo "[OK] Test add user ${USER1}" 42 | else 43 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user ${USER1}: ${RESP}" 44 | fi 45 | 46 | 47 | # Revoke USER1 48 | RESP=$(curl -s -X POST -d 'revoke=true' "${CASSH_SERVER_URL}"/admin/"${USER1}") 49 | if [ "${RESP}" == "Revoke user=${USER1}." ]; then 50 | echo "[OK] Test admin revoke '${USER1}'" 51 | else 52 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin revoke '${USER1}' : ${RESP}" 53 | fi 54 | 55 | # Check if USER1 is revoked on the second server 56 | RESP=$(curl -s -X POST -d 'status=true' "${CASSH_SERVER_2_URL}"/admin/"${USER1}" | jq .status) 57 | if [ "${RESP}" == '"REVOKED"' ]; then 58 | echo "[OK] Test admin verify ${USER1} status is revoked on the server ${CASSH_SERVER_2_URL}" 59 | else 60 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin verify ${USER1} status is revoked on the server ${CASSH_SERVER_2_URL}: ${RESP}" 61 | fi 62 | 63 | 64 | # Get the latest krl 65 | rm -f /tmp/.revoked-keys 66 | curl -s "${CASSH_SERVER_URL}"/krl -o /tmp/.revoked-keys 67 | 68 | # First user should be in the krl 69 | RESP_1=$(ssh-keygen -Q -f /tmp/.revoked-keys "${KEY_1_EXAMPLE}".pub | awk '{print $NF}') 70 | if [ "${RESP_1}" == 'ok' ]; then 71 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test krl for revoked key" 72 | else 73 | echo "[OK] Test krl for revoked key : ${RESP_1}" 74 | fi 75 | RESP_2=$(ssh-keygen -Q -f /tmp/.revoked-keys "${KEY_2_EXAMPLE}".pub | awk '{print $NF}') 76 | if [ "${RESP_2}" == 'ok' ]; then 77 | echo "[OK] Test krl for non-revoked key" 78 | else 79 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test krl for non-revoked key : ${RESP_2}" 80 | fi 81 | 82 | 83 | # Get the latest krl on the second server 84 | rm -f /tmp/.revoked-keys 85 | curl -s "${CASSH_SERVER_2_URL}"/krl -o /tmp/.revoked-keys 86 | 87 | # First user should be in the krl 88 | RESP_1=$(ssh-keygen -Q -f /tmp/.revoked-keys "${KEY_1_EXAMPLE}".pub | awk '{print $NF}') 89 | if [ "${RESP_1}" == 'ok' ]; then 90 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test krl for revoked key on the server ${CASSH_SERVER_2_URL}" 91 | else 92 | echo "[OK] Test krl for revoked key on the server ${CASSH_SERVER_2_URL}: ${RESP_1}" 93 | fi 94 | RESP_2=$(ssh-keygen -Q -f /tmp/.revoked-keys "${KEY_2_EXAMPLE}".pub | awk '{print $NF}') 95 | if [ "${RESP_2}" == 'ok' ]; then 96 | echo "[OK] Test krl for non-revoked key on the server ${CASSH_SERVER_2_URL}" 97 | else 98 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test krl for non-revoked key on the server ${CASSH_SERVER_2_URL}: ${RESP_2}" 99 | fi 100 | -------------------------------------------------------------------------------- /src/client/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | CASSH Client 5 | ----- 6 | 7 | 1.8.1 8 | ----- 9 | 10 | 2022/06/02 11 | 12 | ### Fix 13 | - Fix `timeout` int cast (@dacofr) 14 | - Handling timeout error nicely 15 | 16 | 1.8.0 17 | ----- 18 | 19 | 2021/05/10 20 | 21 | ### Changes 22 | - `expiry` do not require a `+` 23 | 24 | ### Fix 25 | - Fix `expiry` crash (@fedegiova) 26 | 27 | 28 | 1.7.0 29 | ----- 30 | 31 | 2020/04/24 32 | 33 | ### New Features 34 | 35 | - Add --add-principals, --remove-principals, --purge-principals and --update-principals for the admin 'set' action. It's replacing the deprecate 'set' action: `--set='principals=foo,bar`. 36 | - Add --principals-filter for the admin 'search' action. 37 | 38 | ### Changes 39 | - Validate username in the admin command 40 | 41 | 42 | 1.6.3 43 | ----- 44 | 45 | 2020/01/24 46 | 47 | ### Clean 48 | 49 | - Use sys.exit instead of builtin exit 50 | 51 | 52 | 1.6.2 53 | ----- 54 | 55 | 2019/05/23 56 | 57 | ### Bug Fixes 58 | 59 | - fix "Error: No realname option given." : LDAP can be disable/enable 60 | 61 | 62 | 1.6.1 63 | ----- 64 | 65 | 2019/05/22 66 | 67 | ### Other 68 | 69 | - Reorder directories 70 | - Update tests and README 71 | 72 | 73 | 1.6.0 74 | ----- 75 | 76 | 2018/08/23 77 | 78 | ### New Features 79 | 80 | - timeout optional arg in cassh conf file, 2s by default 81 | - verify optional arg in cassh conf file, True by default 82 | - Add a User-Agent `HTTP_USER_AGENT : CASSH-CLIENT v1.6.0` 83 | - Add the client version in header `HTTP_CLIENT_VERSION : 1.6.0` 84 | 85 | 86 | ### Changes 87 | - Read public key as text and not as a binary 88 | - Remove of --uid : "Force UID in key ownership.", useless 89 | - Remove disable_warning() for https requests 90 | 91 | 92 | ### Bug Fixes 93 | 94 | - fix timeout at 60s 95 | - fix no tls certificate verification 96 | - fix README 97 | 98 | ### Other 99 | 100 | - Reorder functions 101 | - Less var in init function, more use of user_metadata shared var 102 | - Wrap request function to unify headers, timeout and tls verification 103 | 104 | 105 | 106 | 1.5.3 107 | ----- 108 | 109 | 2018/08/10 110 | 111 | ### Changes 112 | 113 | - Support ecdsa keys 114 | 115 | 1.5.2 116 | ----- 117 | 118 | 2018/08/10 119 | 120 | ### Bug Fixes 121 | 122 | Use quote_plus on client side to allow complex password 123 | 124 | 1.5.1 125 | ----- 126 | 127 | 2018/08/09 128 | 129 | ### Bug Fixes 130 | 131 | public key upload error in python 3 132 | 133 | 1.5.0 134 | ----- 135 | 136 | 2018/08/09 137 | 138 | ### Changes 139 | 140 | - Every GET routes are DEPRECATED. 141 | - Authentication is in the payload now 142 | - Update tests 143 | 144 | 145 | 1.4.5 146 | ----- 147 | 148 | 2017/12/07 149 | 150 | ### Bug Fixes 151 | 152 | urlencode import error in python 3 153 | 154 | 1.4.4 155 | ----- 156 | 157 | 2017/11/29 158 | 159 | ### Bug Fixes 160 | 161 | Clean admin request. 162 | 163 | 164 | 1.4.3 165 | ----- 166 | 167 | 2017/11/28 168 | 169 | ### Bug Fixes 170 | 171 | Fix non-RSA key issue (v1.4.1). Catch an error when signature begin with 'ssh-'. 172 | 173 | 174 | 1.4.2 175 | ----- 176 | 177 | 2017/11/24 178 | 179 | ### Bug Fixes 180 | 181 | Encoding url params. This change the structure of auth_url function. 182 | 183 | 184 | 1.4.1 185 | ----- 186 | 187 | 2017/11/23 188 | 189 | ### Bug Fixes 190 | 191 | Catch an error when signature begin with 'ssh-rsa-cert'. 192 | 193 | 194 | 195 | 1.4.0 196 | ----- 197 | 198 | 2017/11/21 199 | 200 | ### New Features 201 | 202 | Admin can force the signature when database is unavailable. 203 | 204 | ### Changes 205 | 206 | - Put version into a global variable. 207 | - Correct usage. 208 | 209 | 210 | 1.3.0 211 | ----- 212 | 213 | 2017/11/20 214 | 215 | ### Changes 216 | 217 | Username is provided when you sign a certificate. This is a patch for CASSH Server v1.3.0. 218 | 219 | 220 | 1.2.0 221 | ----- 222 | 223 | 2017/11/17 224 | 225 | ### New Features 226 | 227 | Admin can set parameters like 'expiry' or 'principals'. 228 | 229 | 230 | 1.1.0 231 | ----- 232 | 233 | 2017/10/05 234 | 235 | ### New Features 236 | 237 | Admin can status every user with : cassh admin all status 238 | 239 | ### Changes 240 | 241 | Add 'print_result' function for cassh client 242 | 243 | 244 | 1.0.1 245 | ----- 246 | 247 | 2017/10/04 248 | 249 | ### Bug Fixes 250 | 251 | Fix config file conflict with version 252 | 253 | 254 | 255 | 1.0.0 256 | ----- 257 | 258 | 2017/10/04 259 | 260 | ### Changes 261 | 262 | First stable version 263 | -------------------------------------------------------------------------------- /src/server/web/static/css/typography.css: -------------------------------------------------------------------------------- 1 | /* -------------------------------------------------------------- 2 | 3 | typography.css 4 | * Sets up some sensible default typography. 5 | 6 | -------------------------------------------------------------- */ 7 | 8 | /* Default font settings. 9 | The font-size percentage is of 16px. (0.75 * 16px = 12px) */ 10 | html { font-size:100.01%; } 11 | body { 12 | font-size: 75%; 13 | color: #222; 14 | background: #fff; 15 | font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; 16 | } 17 | 18 | 19 | /* Headings 20 | -------------------------------------------------------------- */ 21 | 22 | h1,h2,h3,h4,h5,h6 { font-weight: normal; color: #111; } 23 | 24 | h1 { font-size: 3em; line-height: 1; margin-bottom: 0.5em; } 25 | h2 { font-size: 2em; margin-bottom: 0.75em; } 26 | h3 { font-size: 1.5em; line-height: 1; margin-bottom: 1em; } 27 | h4 { font-size: 1.2em; line-height: 1.25; margin-bottom: 1.25em; } 28 | h5 { font-size: 1em; font-weight: bold; margin-bottom: 1.5em; } 29 | h6 { font-size: 1em; font-weight: bold; } 30 | 31 | h1 img, h2 img, h3 img, 32 | h4 img, h5 img, h6 img { 33 | margin: 0; 34 | } 35 | 36 | 37 | /* Text elements 38 | -------------------------------------------------------------- */ 39 | 40 | p { margin: 0 0 1.5em; } 41 | p img.left { float: left; margin: 1.5em 1.5em 1.5em 0; padding: 0; } 42 | p img.right { float: right; margin: 1.5em 0 1.5em 1.5em; } 43 | 44 | a:focus, 45 | a:hover { color: #000; } 46 | a { color: #009; text-decoration: underline; } 47 | 48 | blockquote { margin: 1.5em; color: #666; font-style: italic; } 49 | strong { font-weight: bold; } 50 | em,dfn { font-style: italic; } 51 | dfn { font-weight: bold; } 52 | sup, sub { line-height: 0; } 53 | 54 | abbr, 55 | acronym { border-bottom: 1px dotted #666; } 56 | address { margin: 0 0 1.5em; font-style: italic; } 57 | del { color:#666; } 58 | 59 | pre { margin: 1.5em 0; white-space: pre; } 60 | pre,code,tt { font: 1em 'andale mono', 'lucida console', monospace; line-height: 1.5; } 61 | 62 | 63 | /* Lists 64 | -------------------------------------------------------------- */ 65 | 66 | li ul, 67 | li ol { margin: 0; } 68 | ul, ol { margin: 0 1.5em 1.5em 0; padding-left: 3.333em; } 69 | 70 | ul { list-style-type: disc; } 71 | ol { list-style-type: decimal; } 72 | 73 | dl { margin: 0 0 1.5em 0; } 74 | dl dt { font-weight: bold; } 75 | dd { margin-left: 1.5em;} 76 | 77 | 78 | /* Tables 79 | -------------------------------------------------------------- */ 80 | 81 | table { margin-bottom: 1.4em; width:100%; } 82 | th { font-weight: bold; } 83 | thead th { background: #c3d9ff; } 84 | th,td,caption { padding: 4px 10px 4px 5px; } 85 | tr.even td { background: #e5ecf9; } 86 | tfoot { font-style: italic; } 87 | caption { background: #eee; } 88 | 89 | .datagrid table { border-collapse: collapse; text-align: left; width: 100%; } .datagrid {font: normal 12px/150% Arial, Helvetica, sans-serif; background: #fff; overflow: hidden; -webkit-border-radius: 8px; -moz-border-radius: 8px; border-radius: 8px; }.datagrid table td, .datagrid table th { padding: 9px 7px; }.datagrid table thead th {background:-webkit-gradient( linear, left top, left bottom, color-stop(0.05, #1EBEA5), color-stop(1, #128C7E) );background:-moz-linear-gradient( center top, #1EBEA5 5%, #128C7E 100% );filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#1EBEA5', endColorstr='#128C7E');background-color:#1EBEA5; color:#FFFFFF; font-size: 15px; font-weight: bold; border-left: 1px solid #12A178; } .datagrid table thead th:first-child { border: none; }.datagrid table tbody td { color: #000000; font-size: 13px;font-weight: normal; }.datagrid table tbody .alt td { background: #E4F2E7; color: #000000; }.datagrid table tbody td:first-child { border-left: none; }.datagrid table tbody tr:last-child td { border-bottom: none; } 90 | 91 | /* Misc classes 92 | -------------------------------------------------------------- */ 93 | 94 | .small { font-size: .8em; margin-bottom: 1.875em; line-height: 1.875em; } 95 | .large { font-size: 1.2em; line-height: 2.5em; margin-bottom: 1.25em; } 96 | .hide { display: none; } 97 | 98 | .quiet { color: #666; } 99 | .loud { color: #000; } 100 | .highlight { background:#ff0; } 101 | .added { background:#060; color: #fff; } 102 | .removed { background:#900; color: #fff; } 103 | 104 | .first { margin-left:0; padding-left:0; } 105 | .last { margin-right:0; padding-right:0; } 106 | .top { margin-top:0; padding-top:0; } 107 | .bottom { margin-bottom:0; padding-bottom:0; } 108 | -------------------------------------------------------------------------------- /src/server/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | CASSH Server 5 | ----- 6 | 7 | 2.3.1 8 | ----- 9 | 10 | 2022/03/06 11 | 12 | ### New Features 13 | - add ldap starttls support 14 | 15 | ### Changes 16 | - add ldaps+starttls support on tests 17 | 18 | 2.3.0 19 | ----- 20 | 21 | 2022/03/03 22 | 23 | ### New Features 24 | - protocol definition in ldap configuration -> ldaps support (@arnaudmm) 25 | 26 | 2.2.0 27 | ----- 28 | 29 | 2021/03/26 30 | 31 | ### New Features 32 | - `realname` can have upper case (@fedegiova) 33 | 34 | ### Changes 35 | - `expiry` do not require a `+` 36 | 37 | ## Lint 38 | - R1732: Consider using 'with' for resource-allocating operations 39 | - W1401: Anomalous backslash in string: '\d'. String constant might be missing an r prefix. 40 | 41 | 2.1.1 42 | ----- 43 | 44 | 2021/03/26 45 | 46 | ### Changes 47 | - Ability to add numbers in principals 48 | 49 | 2.1.0 50 | ----- 51 | 52 | 2020/09/21 53 | 54 | ### Changes 55 | - Compare ssh public fingerprint instead of raw content 56 | - Force sha512 during fingerprint, instead of default hash algorithm 57 | 58 | 2.0.2 59 | ----- 60 | 61 | 2020/04/09 62 | 63 | ### Bug Fixes 64 | - Handle custom_principals as None (old database entry) 65 | - Urldecode 'add' parameter 66 | 67 | 2.0.1 68 | ----- 69 | 70 | 2020/04/06 71 | 72 | ### Bug Fixes 73 | - Unblock no-membership/no-bindCN user with a valid login/password 74 | 75 | 2.0.0 76 | ----- 77 | 78 | 2020/04/03 79 | 80 | ### New Features 81 | - Add LDAP mapping with principals 82 | - Add multiple options in LDAP configuration, some breaking changes 83 | - Add OpenLDAP in tests 84 | 85 | ### Changes 86 | - LDAP configuration: "filterstr" is deprecated, use "filter_realname_key" instead 87 | - Remove GET /admin/principals (not used in client and not safe) 88 | - Remove deprecated PATCH endpoint for principals 89 | - rename get_principals to clean_principals_output 90 | - add get_ldap_conn, get_memberof, truncate_principals, merge_principals 91 | - Remove py3.4 support (json decoder issues) 92 | 93 | ### Bug Fixes 94 | - ldap_authentification return when bad options 95 | - ldap_authentification uncatch error if no object in LDAP response 96 | 97 | 1.12.2 98 | ----- 99 | 100 | 2020/03/26 101 | 102 | ### Changes 103 | - upgrade requirements 104 | 105 | ### Clean 106 | - Split functions 107 | - Validate all inputs 108 | 109 | ### Bug Fixes 110 | - remove/add wrong imports 111 | - autoreload bug 112 | - duplicates revoke 113 | 114 | 1.12.1 115 | ----- 116 | 117 | 2020/03/24 118 | 119 | ### Bug Fixes 120 | - Remove duplicate principales 121 | 122 | 1.12.0 123 | ----- 124 | 125 | 2020/03/24 126 | 127 | 128 | ### Changes 129 | - New user starts with its username as principal 130 | - Purge set the username as principals, instead of nothing 131 | 132 | ### Bug Fixes 133 | - Unquote action values for Principals and PrincipalsSearch 134 | - Allow multiple actions for Principals 135 | 136 | 1.11.0 137 | ----- 138 | 139 | 2020/03/24 140 | 141 | ### New Features 142 | - Principals CRUD-like endpoint '/admin/ /principals' 143 | - Principals Search endpoint '/admin/all/principals/search' 144 | 145 | ### Changes 146 | - Uniformize PATTERN and reponse 147 | - Warning message when using `PATCH /admin/ ` to update principals 148 | - Split tests 149 | 150 | ### Bug Fixes 151 | - Empty response when no member in cluster, instead of crash 152 | 153 | 1.9.2 154 | ----- 155 | 156 | 2019/07/29 157 | 158 | ### Minor feature 159 | - Allow dash in principals 160 | - Add tests for PATCH command (principals & expiry) 161 | 162 | 163 | 1.9.1 164 | ----- 165 | 166 | 2019/06/13 167 | 168 | ### Bug Fixes 169 | - Fix realname regexp 170 | 171 | 172 | 1.9.0 173 | ----- 174 | 175 | 2019/05/28 176 | 177 | ### New Features 178 | - Generates KRL files by using a database 179 | 180 | ### Changes 181 | - /cluster/updatekrl is removed 182 | - Admin/GET and are removed (it was deprecated) 183 | - Tests are using random usernames 184 | 185 | ### Bug Fixes 186 | 187 | ### Other 188 | - Add function 'get_pubkey' from database, 'timestamp' and 'get_last_krl' 189 | - Remove function 'cluster_last_krl', 'cluster_update_krl' 190 | 191 | 1.8.1 192 | ----- 193 | 194 | 2019/05/28 195 | 196 | ### Bug Fixes 197 | - Add wheel into requirements 198 | - Add build-essential python3-dev dependencies 199 | - Fix cassh.service 200 | 201 | 202 | 1.8.0 203 | ----- 204 | 205 | 2019/05/27 206 | 207 | ### New Features 208 | - Python 3.6 support : 209 | - remove __future__ 210 | - use urllib.parse instead of urllib for unquote_plus 211 | - web.data output in encoded in UTF-8 212 | - Used .keys() for dict 213 | - write temporary files in unicode 214 | - check_output in returning an unicode output 215 | 216 | ### Changes 217 | - Python 2.x deprecated 218 | - Used web.py version 0.40-dev1 instead of 0.39 219 | 220 | 221 | 1.7.3 222 | ----- 223 | 224 | 2019/05/27 225 | 226 | ### New Features 227 | - Add debug parameters in configuration 228 | 229 | ### Changes 230 | - Use ldap version 3.2.0 instead of 2.5.2 (open => initialize) 231 | - Edit Dockerfile and requirements.txt 232 | - Tools: Rename cluster_updatekrl into cluster_update_krl 233 | 234 | 235 | 1.7.2 236 | ----- 237 | 238 | 2019/05/24 239 | 240 | ### Changes 241 | - split tools functions in another library 242 | 243 | 244 | 1.7.1 245 | ----- 246 | 247 | 2019/05/23 248 | 249 | ### Changes 250 | - always return a Content-Type 251 | - Block bad realnames (XSS stored) 252 | - Doesn-t return a blocked username (XSS reflected) 253 | 254 | ### Bug Fixes 255 | - Fix some missing http code 256 | - Fix according tests 257 | 258 | 259 | 1.7.0 260 | ----- 261 | 262 | 2019/05/22 263 | 264 | ### New Features 265 | - Add multi-instance (cluster mode), especially to update the KRL 266 | - ClusterStatus (/cluster/status) : Get the status of the clusted (without auth) 267 | - ClusterUpdateKRL (/cluster/updatekrl) : Update the current KRL to revoke a user, or get the last version of the KRL inside the cluster 268 | - Add a User-Agent `HTTP_USER_AGENT : CASSH-SERVER v1.7.0` 269 | - Add the client version in header `HTTP_SERVER_VERSION : 1.7.0` 270 | - Add cluster and clustersecret parameters in configuration 271 | 272 | ### Changes 273 | - The KRL update is in a separated function 274 | - HTTP code are not always 200 275 | 276 | ### Bug Fixes 277 | - Disable Debug mode (#shame) 278 | 279 | ### Other 280 | - More tests, with random ssh-key and username 281 | - More documentation 282 | -------------------------------------------------------------------------------- /tests/test_principals_search.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # shellcheck disable=SC2128 3 | 4 | 5 | RESP=$(curl -s -X POST "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=") 6 | if [[ "${RESP}" == "Error: No realname option given." ]]; then 7 | echo "[OK] Test search principals without realname,password" 8 | else 9 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search all users' principals without realname,password: ${RESP}" 10 | fi 11 | 12 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=") 13 | if [[ "${RESP}" == "Error: No password option given." ]]; then 14 | echo "[OK] Test search principals without password" 15 | else 16 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search all users' principals without password: ${RESP}" 17 | fi 18 | 19 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${GUEST_B_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=") 20 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 21 | echo "[OK] Test search principals with invalid credentials" 22 | else 23 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search all users' principals with invalid credentials: ${RESP}" 24 | fi 25 | 26 | RESP=$(curl -s -X POST -d "realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=") 27 | if [[ "${RESP}" == "Error: Not authorized." ]]; then 28 | echo "[OK] Test search principals with unauthorized user" 29 | else 30 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search all users' principals with unauthorized user: ${RESP}" 31 | fi 32 | 33 | ################################# 34 | ## ADMIN SEARCH ALL PRINCIPALS ## 35 | ################################# 36 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=") 37 | if [[ "${RESP}" == *"{\""* ]]; then 38 | echo "[OK] Test search all users' principals: ${RESP}" 39 | else 40 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search all users' principals: ${RESP}" 41 | fi 42 | 43 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=dontexists") 44 | if [ "${RESP}" == "{}" ]; then 45 | echo "[OK] Test search unknown principals" 46 | else 47 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search unknown principals : ${RESP}" 48 | fi 49 | 50 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=test-multiple-${GUEST_B_USERNAME}") 51 | if [ "${RESP}" == "{\"${GUEST_B_USERNAME}\": [\"test-multiple-${GUEST_B_USERNAME}\"]}" ]; then 52 | echo "[OK] Test search test-multiple-${GUEST_B_USERNAME} principal" 53 | else 54 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search test-multiple-${GUEST_B_USERNAME} principal : ${RESP}" 55 | fi 56 | 57 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_C_USERNAME}"/principals -d "add=test-multiple-${GUEST_B_USERNAME}") 58 | if [ "${RESP}" == "OK: ${GUEST_C_USERNAME} principals are '${GUEST_C_USERNAME},test-multiple-${GUEST_B_USERNAME},guest-everywhere'" ]; then 59 | echo "[OK] Test add principal 'test-multiple-${GUEST_B_USERNAME}' to ${GUEST_C_USERNAME}" 60 | else 61 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add principal 'test-multiple-${GUEST_B_USERNAME}' to ${GUEST_C_USERNAME} : ${RESP}" 62 | fi 63 | 64 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=test-multiple-${GUEST_B_USERNAME}") 65 | if [[ "${RESP}" == *"\"${GUEST_C_USERNAME}\": [\"test-multiple-${GUEST_B_USERNAME}\""* ]] && [[ "${RESP}" == *"\"${GUEST_B_USERNAME}\": [\"test-multiple-${GUEST_B_USERNAME}\""* ]]; then 66 | echo "[OK] Test search single principals with multiple users" 67 | else 68 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search single principals with multiple users : ${RESP}" 69 | fi 70 | 71 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=test-multiple-${GUEST_B_USERNAME},unknown") 72 | if [[ "${RESP}" == *"\"${GUEST_C_USERNAME}\": [\"test-multiple-${GUEST_B_USERNAME}\""* ]] && [[ "${RESP}" == *"\"${GUEST_B_USERNAME}\": [\"test-multiple-${GUEST_B_USERNAME}\""* ]]; then 73 | echo "[OK] Test search multiple principals with one unknown" 74 | else 75 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search multiple principals with one unknown : ${RESP}" 76 | fi 77 | 78 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=test-multiple-${GUEST_B_USERNAME},${BADTEXT}") 79 | if [ "${RESP}" == "Error: invalid filter." ]; then 80 | echo "[OK] Test search multiple principals with one bad value" 81 | else 82 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search multiple principals with one bad value : ${RESP}" 83 | fi 84 | 85 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_C_USERNAME}"/principals -d "remove=test-multiple-${GUEST_B_USERNAME}") 86 | if [ "${RESP}" == "OK: ${GUEST_C_USERNAME} principals are '${GUEST_C_USERNAME},guest-everywhere'" ]; then 87 | echo "[OK] Test remove principal 'test-multiple-${GUEST_B_USERNAME}' to ${GUEST_C_USERNAME}" 88 | else 89 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test remove principal 'test-multiple-${GUEST_B_USERNAME}' to ${GUEST_C_USERNAME} : ${RESP}" 90 | fi 91 | 92 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "filter=test-multiple-${GUEST_B_USERNAME},${GUEST_C_USERNAME}") 93 | if [[ "${RESP}" == *"\"${GUEST_C_USERNAME}\": [\"${GUEST_C_USERNAME}\""* ]] && [[ "${RESP}" == *"\"${GUEST_B_USERNAME}\": [\"test-multiple-${GUEST_B_USERNAME}\""* ]]; then 94 | echo "[OK] Test search multiple principals with multiple users" 95 | else 96 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search multiple principals with multiple users : ${RESP}" 97 | fi 98 | 99 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "unknown=action") 100 | if [ "${RESP}" == "[ERROR] Unknown action" ]; then 101 | echo "[OK] Test search with unknown action" 102 | else 103 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search with unknown action : ${RESP}" 104 | fi 105 | 106 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/all/principals/search -d "${BADTEXT}") 107 | if [ "${RESP}" == "[ERROR] Unknown action" ]; then 108 | echo "[OK] Test search with garbage" 109 | else 110 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test search with garbage : ${RESP}" 111 | fi 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CASSH 2 | 3 | OpenSSH features reach their limit when it comes to industrialization. We don’t want an administrator to sign every user’s public key by hand every day, so we need a service for that. That is exactly the purpose of CASSH: **signing keys**! 4 | Developped for @leboncoin 5 | 6 | https://medium.com/leboncoin-engineering-blog/cassh-ssh-key-signing-tool-39fd3b8e4de7 7 | 8 | - [CLI version : **1.8.1** *(02/06/2022)*](src/client/CHANGELOG.md)  +  [](https://hub.docker.com/r/nbeguier/cassh-client) 9 | - [WebUI version : **1.3.1** *(02/06/2022)*](src/server/web/CHANGELOG.md)  [](https://hub.docker.com/r/nbeguier/cassh-web) 10 | - [Server version : **2.3.1** *(06/03/2022)*](src/server/CHANGELOG.md)  +  [](https://hub.docker.com/r/nbeguier/cassh-server) 11 | 12 | ## Usage 13 | 14 | ### Client CLI 15 | 16 | Add new key to cassh-server : 17 | ``` 18 | cassh add 19 | ``` 20 | 21 | Sign pub key : 22 | ``` 23 | cassh sign [--display-only] [--force] 24 | ``` 25 | 26 | Get public key status : 27 | ``` 28 | cassh status 29 | ``` 30 | 31 | Get ca public key : 32 | ``` 33 | cassh ca 34 | ``` 35 | 36 | Get ca krl : 37 | ``` 38 | cassh krl 39 | ``` 40 | 41 | ### Admin CLI 42 | 43 | ``` 44 | usage: cassh admin [-h] [-s SET] [--add-principals ADD_PRINCIPALS] 45 | [--remove-principals REMOVE_PRINCIPALS] 46 | [--purge-principals] 47 | [--update-principals UPDATE_PRINCIPALS] 48 | [--principals-filter PRINCIPALS_FILTER] 49 | username action 50 | 51 | positional arguments: 52 | username Username of client's key, if username is 'all' status 53 | return all users 54 | action Choice between : active, delete, revoke, set, search, 55 | status keys 56 | 57 | optional arguments: 58 | -h, --help show this help message and exit 59 | -s SET, --set SET CAUTION: Set value of a user. 60 | --add-principals ADD_PRINCIPALS 61 | Add a list of principals to a user, should be 62 | separated by comma without spaces. 63 | --remove-principals REMOVE_PRINCIPALS 64 | Remove a list of principals to a user, should be 65 | separated by comma without spaces. 66 | --purge-principals Purge all principals to a user. 67 | --update-principals UPDATE_PRINCIPALS 68 | Update all principals to a user by the given 69 | principals, should be separated by comma without 70 | spaces. 71 | --principals-filter PRINCIPALS_FILTER 72 | Look for users by the given principals filter, should 73 | be separated by comma without spaces. 74 | ``` 75 | 76 | Active Client **username** key : 77 | ``` 78 | cassh admin active 79 | ``` 80 | 81 | Revoke Client **username** key : 82 | ``` 83 | cassh admin revoke 84 | ``` 85 | 86 | Delete Client **username** key : 87 | ``` 88 | cassh admin delete 89 | ``` 90 | 91 | Status Client **username** key : 92 | ``` 93 | cassh admin status 94 | ``` 95 | 96 | Set Client **username** key : 97 | ``` 98 | # Set expiry to 7 days 99 | cassh admin set --set='expiry=7d' 100 | 101 | # Add principals to existing ones 102 | cassh admin set --add-principals foo,bar 103 | 104 | # Remove principals from existing ones 105 | cassh admin set --remove-principals foo,bar 106 | 107 | # Update principals and erease existsing ones 108 | cassh admin set --update-principals foo,bar 109 | 110 | # Purge principals 111 | cassh admin set --purge-principals 112 | ``` 113 | 114 | Search **Principals** among clients : 115 | ``` 116 | cassh admin all search --principals-filter foo,bar 117 | ``` 118 | 119 | ## Install 120 | 121 | ### Server 122 | 123 | [INSTALL.md](src/server/INSTALL.md) 124 | 125 | ### Client 126 | 127 | [INSTALL.md](src/client/INSTALL.md) 128 | 129 | ### Cassh WebUI 130 | 131 | [INSTALL.md](src/server/web/INSTALL.md) 132 | 133 | 134 | ## Quick test 135 | 136 | ### Server side 137 | 138 | Install docker : https://docs.docker.com/engine/installation/ 139 | 140 | #### Prerequisites 141 | 142 | ```bash 143 | # install utilities needed by tests/test.sh 144 | sudo apt install pwgen jq 145 | 146 | # Make a 'sudo' only if your user doesn't have docker rights, add your user into docker group 147 | pip install -r tests/requirements.txt 148 | 149 | cp tests/cassh/cassh.conf.sample tests/cassh/cassh.conf 150 | cp tests/cassh/ldap_mapping.json.sample tests/cassh/ldap_mapping.json 151 | 152 | # Edit cassh.conf file to configure the hosts 153 | 154 | # Generate temporary certificates 155 | mkdir test-keys 156 | ssh-keygen -C CA -t rsa -b 4096 -o -a 100 -N "" -f test-keys/id_rsa_ca # without passphrase 157 | ssh-keygen -k -f test-keys/revoked-keys 158 | 159 | ############################################ 160 | # BEGIN THE ONE OR MULTIPLE INSTANCES STEP # 161 | ############################################ 162 | 163 | # Duplicate the cassh.conf 164 | cp tests/cassh/cassh.conf tests/cassh/cassh_2.conf 165 | # Generate another krl 166 | ssh-keygen -k -f test-keys/revoked-keys-2 167 | sed -i "s/revoked-keys/revoked-keys-2/g" tests/cassh/cassh_2.conf 168 | ``` 169 | 170 | #### One instance 171 | 172 | 173 | ```bash 174 | # Launch this on another terminal 175 | bash tests/launch_demo_server.sh --server_code_path ${PWD} --debug 176 | $ /opt/cassh/src/server/server.py --config /opt/cassh/tests/cassh/cassh.conf 177 | 178 | # When 'http://0.0.0.0:8080/' appears, start this script 179 | bash tests/test.sh 180 | ``` 181 | 182 | #### Multiple instances 183 | 184 | The same as previsouly, but launch this to specify a second cassh-server instance 185 | 186 | ```bash 187 | # Launch this on another terminal 188 | bash tests/launch_demo_server.sh --server_code_path ${PWD} --debug --port 8081 189 | $ /opt/cassh/src/server/server.py --config /opt/cassh/tests/cassh/cassh_2.conf 190 | ``` 191 | 192 | 193 | ### Client side 194 | 195 | Generate key pair then sign it ! 196 | 197 | ```bash 198 | git clone https://github.com/nbeguier/cassh.git /opt/cassh 199 | cd /opt/cassh 200 | 201 | # Generate key pair 202 | mkdir test-keys 203 | ssh-keygen -t rsa -b 4096 -o -a 100 -f test-keys/id_rsa 204 | 205 | rm -f ~/.cassh 206 | cat << EOF > ~/.cassh 207 | [user] 208 | name = user 209 | key_path = ${PWD}/test-keys/id_rsa 210 | key_signed_path = ${PWD}/test-keys/id_rsa-cert 211 | url = http://localhost:8080 212 | 213 | [ldap] 214 | realname = user@test.fr 215 | EOF 216 | 217 | # List keys 218 | python cassh status 219 | 220 | # Add it into server 221 | python cassh add 222 | 223 | # ADMIN: Active key 224 | python cassh admin user active 225 | 226 | # Sign it ! 227 | python cassh sign [--display-only] 228 | ``` 229 | 230 | # License 231 | Licensed under the [Apache License](https://github.com/nbeguier/cassh/blob/master/LICENSE), Version 2.0 (the "License"). 232 | 233 | # Copyright 234 | Copyright 2017-2025 Nicolas BEGUIER; ([nbeguier](https://beguier.eu/nicolas/) - nicolas_beguier[at]hotmail[dot]com) 235 | -------------------------------------------------------------------------------- /tests/test_client_add.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # shellcheck disable=SC2128 3 | 4 | RESP=$(curl -s -X PUT "${CASSH_SERVER_URL}"/client) 5 | if [ "${RESP}" == 'Error: No realname option given.' ]; then 6 | echo "[OK] Test add user without username,realname,password" 7 | else 8 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user without username,realname,password : ${RESP}" 9 | fi 10 | 11 | RESP=$(curl -s -X PUT -d "realname=${GUEST_A_REALNAME}" "${CASSH_SERVER_URL}"/client) 12 | if [ "${RESP}" == 'Error: No password option given.' ]; then 13 | echo "[OK] Test add user without username,password" 14 | else 15 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user without username,password : ${RESP}" 16 | fi 17 | 18 | RESP=$(curl -s -X PUT -d "realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/client) 19 | if [ "${RESP}" == 'Error: No username option given.' ]; then 20 | echo "[OK] Test add user without username" 21 | else 22 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user without username : ${RESP}" 23 | fi 24 | 25 | RESP=$(curl -s -X PUT -d 'username=test_user' "${CASSH_SERVER_URL}"/client) 26 | if [ "${RESP}" == "Error: invalid username." ]; then 27 | echo "[OK] Test add user with bad username" 28 | else 29 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user with bad username : ${RESP}" 30 | fi 31 | 32 | RESP=$(curl -s -X PUT -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/client) 33 | if [ "${RESP}" == 'Error: No pubkey given.' ]; then 34 | echo "[OK] Test add user with no pubkey" 35 | else 36 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user with no pubkey : ${RESP}" 37 | fi 38 | 39 | RESP=$(curl -s -X PUT -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}&pubkey=bad_pubkey" "${CASSH_SERVER_URL}"/client) 40 | if [ "${RESP}" == 'Error : Public key unprocessable' ]; then 41 | echo "[OK] Test add user with bad pubkey" 42 | else 43 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user with bad pubkey : ${RESP}" 44 | fi 45 | 46 | RESP=$(curl -s -X PUT -d "username=${GUEST_A_USERNAME}&realname=${BADTEXT}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 47 | if [ "${RESP}" == "Error: invalid realname." ]; then 48 | echo "[OK] Test add user with bad realname" 49 | else 50 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user with bad realname : ${RESP}" 51 | fi 52 | 53 | RESP=$(curl -s -X PUT -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 54 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 55 | echo "[OK] Test add user with invalid credentials" 56 | else 57 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user with invalid credentials : ${RESP}" 58 | fi 59 | 60 | RESP=$(curl -s -X PUT -d "username=all&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 61 | if [ "${RESP}" == "Error: username not valid." ]; then 62 | echo "[OK] Test add user named 'all'" 63 | else 64 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user named 'all' : ${RESP}" 65 | fi 66 | 67 | RESP=$(curl -s -X PUT -d "username=${BADTEXT}&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 68 | if [ "${RESP}" == "Error: invalid username." ]; then 69 | echo "[OK] Test add bad username" 70 | else 71 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add bad username : ${RESP}" 72 | fi 73 | 74 | ################# 75 | ## ADD GUEST A ## 76 | ################# 77 | RESP=$(curl -s -X PUT -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 78 | if [ "${RESP}" == "Create user=${GUEST_A_USERNAME}. Pending request." ]; then 79 | echo "[OK] Test add user ${GUEST_A_USERNAME}" 80 | else 81 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user ${GUEST_A_USERNAME} : ${RESP}" 82 | fi 83 | 84 | ################# 85 | ## ADD GUEST B ## 86 | ################# 87 | RESP=$(curl -s -X PUT -d "username=${GUEST_B_USERNAME}&realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_B_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 88 | if [ "${RESP}" == "Create user=${GUEST_B_USERNAME}. Pending request." ]; then 89 | echo "[OK] Test add user ${GUEST_B_USERNAME}" 90 | else 91 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user ${GUEST_B_USERNAME}: ${RESP}" 92 | fi 93 | 94 | ################# 95 | ## ADD GUEST C ## 96 | ################# 97 | RESP=$(curl -s -X PUT -d "username=${GUEST_C_USERNAME}&realname=${GUEST_C_REALNAME}&password=${GUEST_C_PASSWORD}&pubkey=${GUEST_C_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 98 | if [ "${RESP}" == "Create user=${GUEST_C_USERNAME}. Pending request." ]; then 99 | echo "[OK] Test add user with same realname (which is possible)" 100 | else 101 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user with same realname (which is possible): ${RESP}" 102 | fi 103 | 104 | ################## 105 | ## ADD SYSADMIN ## 106 | ################## 107 | RESP=$(curl -s -X PUT -d "username=${SYSADMIN_USERNAME}&realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}&pubkey=${SYSADMIN_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 108 | if [ "${RESP}" == "Create user=${SYSADMIN_USERNAME}. Pending request." ]; then 109 | echo "[OK] Test add user sysadmin" 110 | else 111 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user sysadmin: ${RESP}" 112 | fi 113 | 114 | RESP=$(curl -s -X POST -d "realname=${GUEST_A_REALNAME}&password=${GUEST_B_PASSWORD}" "${CASSH_SERVER_URL}"/client/status) 115 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 116 | echo "[OK] Test status with invalid credentials" 117 | else 118 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test status with invalid credentials : ${RESP}" 119 | fi 120 | 121 | #################### 122 | ## STATUS GUEST A ## 123 | #################### 124 | RESP=$(curl -s -X POST -d "realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/client/status | jq .status) 125 | if [ "${RESP}" == '"PENDING"' ]; then 126 | echo "[OK] Test status pending user" 127 | else 128 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test status pending user : ${RESP}" 129 | fi 130 | 131 | RESP=$(curl -s -X PUT -d "username=${GUEST_C_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_B_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 132 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 133 | echo "[OK] Test updating user with invalid credentials" 134 | else 135 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test updating user with invalid credentials: ${RESP}" 136 | fi 137 | 138 | #################### 139 | ## UPDATE GUEST C ## 140 | #################### 141 | RESP=$(curl -s -X PUT -d "username=${GUEST_C_USERNAME}&realname=${GUEST_C_REALNAME}&password=${GUEST_C_PASSWORD}&pubkey=${GUEST_B_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 142 | if [ "${RESP}" == "Update user=${GUEST_C_USERNAME}. Pending request." ]; then 143 | echo "[OK] Test updating user " 144 | else 145 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test updating user: ${RESP}" 146 | fi 147 | 148 | ##################### 149 | ## RESTORE GUEST C ## 150 | ##################### 151 | RESP=$(curl -s -X PUT -d "username=${GUEST_C_USERNAME}&realname=${GUEST_C_REALNAME}&password=${GUEST_C_PASSWORD}&pubkey=${GUEST_C_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 152 | if [ "${RESP}" == "Update user=${GUEST_C_USERNAME}. Pending request." ]; then 153 | echo "[OK] Test updating user (restore original pub key)" 154 | else 155 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test updating user (restore original pub key): ${RESP}" 156 | fi 157 | 158 | RESP=$(curl -s -X PUT -d "username=${GUEST_A_USERNAME}&realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 159 | if [ "${RESP}" == 'Error : (username, realname) couple mismatch.' ]; then 160 | echo "[OK] Test add user with same username (should fail)" 161 | else 162 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user with same username (should fail): ${RESP}" 163 | fi 164 | 165 | RESP=$(curl -s -X PUT -d "username=${GUEST_A_USERNAME}&realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_B_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 166 | if [ "${RESP}" == 'Error : (username, realname) couple mismatch.' ]; then 167 | echo "[OK] Test add user with same username (should fail)" 168 | else 169 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add user with same username (should fail): ${RESP}" 170 | fi 171 | -------------------------------------------------------------------------------- /src/server/web/cassh_web.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | #-*- coding: utf-8 -*- 3 | """ 4 | Cassh WEB Client 5 | 6 | Copyright 2017-2025 Nicolas BEGUIER 7 | Licensed under the Apache License, Version 2.0 8 | Written by Nicolas BEGUIER (nicolas_beguier@hotmail.com) 9 | 10 | """ 11 | 12 | # Standard library imports 13 | from __future__ import print_function 14 | from base64 import urlsafe_b64decode, urlsafe_b64encode 15 | from datetime import datetime 16 | from functools import wraps 17 | from json import loads 18 | from os import environ, getenv, path 19 | from ssl import PROTOCOL_TLSv1_2, SSLContext 20 | import sys 21 | 22 | # Third party library imports 23 | from flask import Flask, render_template, request, Response, redirect, send_from_directory 24 | from requests import post, put 25 | from requests.exceptions import ConnectionError 26 | from urllib3 import disable_warnings 27 | 28 | # Disable HTTPs warnings 29 | disable_warnings() 30 | 31 | # Debug 32 | # from pdb import set_trace as st 33 | 34 | VERSION = '1.3.1' 35 | APP = Flask(__name__) 36 | # Read settings file by default, but can be missing 37 | try: 38 | APP.config.from_pyfile('settings.txt') 39 | except FileNotFoundError: 40 | pass 41 | 42 | # Override optionnal settings.txt file 43 | for env_var in [ 44 | 'CASSH_URL', 45 | 'DEBUG', 46 | 'ENABLE_LDAP', 47 | 'ENCRYPTION_KEY', 48 | 'LOGIN_BANNER', 49 | 'PORT', 50 | 'LISTEN', 51 | 'SSL_PRIV_KEY', 52 | 'SSL_PUB_KEY', 53 | 'UPLOAD_FOLDER', 54 | ]: 55 | if environ.get(env_var): 56 | if env_var in ['ENABLE_LDAP', 'DEBUG']: 57 | APP.config[env_var] = environ.get(env_var) == 'True' 58 | else: 59 | APP.config[env_var] = environ.get(env_var) 60 | elif env_var not in APP.config: 61 | print('Error: {} is not present in configuration...'.format(env_var)) 62 | sys.exit(1) 63 | # These are the extension that we are accepting to be uploaded 64 | APP.config['ALLOWED_EXTENSIONS'] = set(['pub']) 65 | APP.config['HEADERS'] = { 66 | 'User-Agent': 'CASSH-WEB-CLIENT v%s' % VERSION, 67 | 'CLIENT_VERSION': VERSION, 68 | } 69 | 70 | def allowed_file(filename): 71 | """ For a given file, return whether it's an allowed type or not """ 72 | return '.' in filename and \ 73 | filename.rsplit('.', 1)[1] in APP.config['ALLOWED_EXTENSIONS'] 74 | 75 | def self_decode(key, enc): 76 | dec = [] 77 | # Try to use urlsafe_b64decode before encoding 78 | try: 79 | encoded = urlsafe_b64decode(enc).decode() 80 | except TypeError: 81 | encoded = urlsafe_b64decode(enc.encode()) 82 | for i in range(len(encoded)): 83 | key_c = key[i % len(key)] 84 | dec_c = chr((256 + ord(encoded[i]) - ord(key_c)) % 256) 85 | dec.append(dec_c) 86 | return "".join(dec) 87 | 88 | def self_encode(key, clear): 89 | enc = [] 90 | for i in range(len(clear)): 91 | key_c = key[i % len(key)] 92 | enc_c = chr((ord(clear[i]) + ord(key_c)) % 256) 93 | enc.append(enc_c) 94 | # Try to encode in unicode before urlsafe_b64encode 95 | try: 96 | encoded = urlsafe_b64encode("".join(enc).encode()).decode() 97 | except UnicodeDecodeError: 98 | encoded = urlsafe_b64encode("".join(enc)) 99 | return encoded 100 | 101 | def requires_auth(func): 102 | """ Wrapper which force authentication """ 103 | @wraps(func) 104 | def decorated(*args, **kwargs): 105 | """ Authentication wrapper """ 106 | current_user = {} 107 | current_user['name'] = request.cookies.get('username') 108 | try: 109 | current_user['password'] = self_decode(APP.config['ENCRYPTION_KEY'], request.cookies.get('password')) 110 | except: 111 | current_user['password'] = 'Unknown' 112 | current_user['is_authenticated'] = request.cookies.get('last_attempt_error') == 'False' 113 | if current_user['name'] == 'Unknown' and current_user['password'] == 'Unknown': 114 | current_user['is_authenticated'] = False 115 | return func(current_user=current_user, *args, **kwargs) 116 | return decorated 117 | 118 | @APP.route('/') 119 | @requires_auth 120 | def index(current_user=None): 121 | """ Display home page """ 122 | return render_template('homepage.html', username=current_user['name'], \ 123 | logged_in=current_user['is_authenticated'], \ 124 | display_error=request.cookies.get('last_attempt_error') == 'True', \ 125 | login_banner=APP.config['LOGIN_BANNER']) 126 | 127 | @APP.route('/login', methods=['POST']) 128 | @requires_auth 129 | def login(current_user=None): 130 | """ 131 | Authentication 132 | """ 133 | del current_user 134 | username = request.form['username'] 135 | password = request.form['password'] 136 | last_attempt_error = False 137 | redirect_to_index = redirect('/') 138 | response = APP.make_response(redirect_to_index) 139 | try: 140 | payload = {} 141 | payload.update({'realname': username, 'password': password}) 142 | req = post(APP.config['CASSH_URL'] + '/test_auth', \ 143 | data=payload, \ 144 | headers=APP.config['HEADERS'], \ 145 | verify=False) 146 | except: 147 | return Response('Connection error : %s' % APP.config['CASSH_URL']) 148 | if 'OK' in req.text: 149 | response.set_cookie('username', value=username) 150 | response.set_cookie('password', value=self_encode(APP.config['ENCRYPTION_KEY'], password)) 151 | else: 152 | last_attempt_error = True 153 | response.set_cookie('last_attempt_error', value=str(last_attempt_error)) 154 | return response 155 | 156 | @APP.route('/logout', methods=['POST']) 157 | @requires_auth 158 | def logout(current_user=None): 159 | redirect_to_index = redirect('/') 160 | response = APP.make_response(redirect_to_index) 161 | response.set_cookie('username', value='Unknown') 162 | response.set_cookie('password', value='Unknown') 163 | response.set_cookie('last_attempt_error', value='False') 164 | return response 165 | 166 | @APP.route('/add/') 167 | @requires_auth 168 | def cassh_add(current_user=None): 169 | """ Display add key page """ 170 | return render_template('add.html', username=current_user['name'], \ 171 | logged_in=current_user['is_authenticated']) 172 | 173 | @APP.route('/sign/') 174 | @requires_auth 175 | def cassh_sign(current_user=None): 176 | """ Display sign page """ 177 | return render_template('sign.html', username=current_user['name'], \ 178 | logged_in=current_user['is_authenticated']) 179 | 180 | @APP.route('/status/') 181 | @requires_auth 182 | def cassh_status(current_user=None): 183 | """ 184 | CASSH status 185 | """ 186 | try: 187 | payload = {} 188 | payload.update({'realname': current_user['name'], 'password': current_user['password']}) 189 | req = post(APP.config['CASSH_URL'] + '/client/status', \ 190 | data=payload, \ 191 | headers=APP.config['HEADERS'], \ 192 | verify=False) 193 | except ConnectionError: 194 | return Response('Connection error : %s' % APP.config['CASSH_URL']) 195 | try: 196 | result = loads(req.text) 197 | is_expired = datetime.strptime(result['expiration'], '%Y-%m-%d %H:%M:%S') < datetime.now() 198 | if result['status'] == 'ACTIVE': 199 | if is_expired: 200 | result['status'] = 'EXPIRED' 201 | else: 202 | result['status'] = 'SIGNED' 203 | except: 204 | result = req.text 205 | 206 | return render_template('status.html', username=current_user['name'], result=result, \ 207 | logged_in=current_user['is_authenticated']) 208 | 209 | # Route that will process the file upload 210 | @APP.route('/sign/upload', methods=['POST']) 211 | @requires_auth 212 | def upload(current_user=None): 213 | """ 214 | CASSH sign 215 | """ 216 | pubkey = request.files['file'] 217 | username = request.form['username'] 218 | payload = {} 219 | payload.update({'realname': current_user['name'], 'password': current_user['password']}) 220 | payload.update({'username': username}) 221 | payload.update({'pubkey': pubkey.read().decode('UTF-8')}) 222 | try: 223 | req = post(APP.config['CASSH_URL'] + '/client', \ 224 | data=payload, \ 225 | headers=APP.config['HEADERS'], \ 226 | verify=False) 227 | except ConnectionError: 228 | return Response('Connection error : %s' % APP.config['CASSH_URL']) 229 | if 'Error' in req.text: 230 | return Response(req.text) 231 | 232 | with open(path.join(APP.config['UPLOAD_FOLDER'], current_user['name']), 'w') as f: 233 | f.write(req.text) 234 | 235 | return send_from_directory(APP.config['UPLOAD_FOLDER'], current_user['name'], \ 236 | attachment_filename='id_rsa-cert.pub', as_attachment=True) 237 | 238 | # Route that will process the file upload 239 | @APP.route('/add/send', methods=['POST']) 240 | @requires_auth 241 | def send(current_user=None): 242 | """ 243 | CASSH add 244 | """ 245 | pubkey = request.files['file'] 246 | username = request.form['username'] 247 | payload = {} 248 | payload.update({'realname': current_user['name'], 'password': current_user['password']}) 249 | payload.update({'username': username}) 250 | payload.update({'pubkey': pubkey.read().decode('UTF-8')}) 251 | try: 252 | req = put(APP.config['CASSH_URL'] + '/client', \ 253 | data=payload, \ 254 | headers=APP.config['HEADERS'], \ 255 | verify=False) 256 | except ConnectionError: 257 | return Response('Connection error : %s' % APP.config['CASSH_URL']) 258 | if 'Error' in req.text: 259 | return Response(req.text) 260 | return redirect('/status') 261 | 262 | @APP.errorhandler(404) 263 | def page_not_found(_): 264 | """ Display error page """ 265 | return render_template('404.html'), 404 266 | 267 | if __name__ == '__main__': 268 | CONTEXT = SSLContext(PROTOCOL_TLSv1_2) 269 | CONTEXT.load_cert_chain(APP.config['SSL_PUB_KEY'], APP.config['SSL_PRIV_KEY']) 270 | PORT = int(getenv('PORT', APP.config['PORT'])) 271 | APP.run(debug=APP.config['DEBUG'], host=APP.config['LISTEN'], port=PORT, ssl_context=CONTEXT) 272 | -------------------------------------------------------------------------------- /tests/test_admin_activate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # shellcheck disable=SC2128 3 | 4 | RESP=$(curl -s -X POST -d 'revoke=true' "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 5 | if [ "${RESP}" == "Error: No realname option given." ]; then 6 | echo "[OK] Test admin revoke '${GUEST_A_USERNAME}' without realname,password" 7 | else 8 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin revoke '${GUEST_A_USERNAME}' without realname,password : ${RESP}" 9 | fi 10 | 11 | RESP=$(curl -s -X POST -d "revoke=true&realname=${SYSADMIN_REALNAME}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 12 | if [ "${RESP}" == "Error: No password option given." ]; then 13 | echo "[OK] Test admin revoke '${GUEST_A_USERNAME}' without password" 14 | else 15 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin revoke '${GUEST_A_USERNAME}' without password : ${RESP}" 16 | fi 17 | 18 | RESP=$(curl -s -X POST -d "revoke=true&realname=${SYSADMIN_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 19 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 20 | echo "[OK] Test admin revoke '${GUEST_A_USERNAME}' with invalid credentials" 21 | else 22 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin revoke '${GUEST_A_USERNAME}' with invalid credentials : ${RESP}" 23 | fi 24 | 25 | RESP=$(curl -s -X POST -d "revoke=true&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 26 | if [ "${RESP}" == "Error: Not authorized." ]; then 27 | echo "[OK] Test admin revoke '${GUEST_A_USERNAME}' with unauthorized user" 28 | else 29 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin revoke '${GUEST_A_USERNAME}' with unauthorized user : ${RESP}" 30 | fi 31 | 32 | ########################## 33 | ## ADMIN REVOKE GUEST A ## 34 | ########################## 35 | RESP=$(curl -s -X POST -d "revoke=true&realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 36 | if [ "${RESP}" == "Revoke user=${GUEST_A_USERNAME}." ]; then 37 | echo "[OK] Test admin revoke '${GUEST_A_USERNAME}'" 38 | else 39 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin revoke '${GUEST_A_USERNAME}' : ${RESP}" 40 | fi 41 | 42 | RESP=$(curl -s -X POST -d "revoke=true&realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 43 | if [ "${RESP}" == "user ${GUEST_A_USERNAME} already revoked." ]; then 44 | echo "[OK] Test admin revoke '${GUEST_A_USERNAME}' again (should fail)" 45 | else 46 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin revoke '${GUEST_A_USERNAME}' again (should fail) : ${RESP}" 47 | fi 48 | 49 | RESP=$(curl -s -X POST -d 'status=true' "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 50 | if [ "${RESP}" == "Error: No realname option given." ]; then 51 | echo "[OK] Test admin verify '${GUEST_A_USERNAME}' status without realname,password" 52 | else 53 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin verify '${GUEST_A_USERNAME}' status without realname,password: ${RESP}" 54 | fi 55 | 56 | RESP=$(curl -s -X POST -d "status=true&realname=${SYSADMIN_REALNAME}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 57 | if [ "${RESP}" == "Error: No password option given." ]; then 58 | echo "[OK] Test admin verify '${GUEST_A_USERNAME}' status without password" 59 | else 60 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin verify '${GUEST_A_USERNAME}' status without password: ${RESP}" 61 | fi 62 | 63 | RESP=$(curl -s -X POST -d "status=true&realname=${SYSADMIN_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 64 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 65 | echo "[OK] Test admin verify '${GUEST_A_USERNAME}' status with invalid credentials" 66 | else 67 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin verify '${GUEST_A_USERNAME}' status with invalid credentials: ${RESP}" 68 | fi 69 | 70 | RESP=$(curl -s -X POST -d "status=true&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 71 | if [ "${RESP}" == "Error: Not authorized." ]; then 72 | echo "[OK] Test admin verify '${GUEST_A_USERNAME}' status with unauthorized user" 73 | else 74 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin verify '${GUEST_A_USERNAME}' status with unauthorized user: ${RESP}" 75 | fi 76 | 77 | ########################## 78 | ## ADMIN STATUS GUEST A ## 79 | ########################## 80 | RESP=$(curl -s -X POST -d "status=true&realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}" | jq .status) 81 | if [ "${RESP}" == '"REVOKED"' ]; then 82 | echo "[OK] Test admin verify '${GUEST_A_USERNAME}' status" 83 | else 84 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin verify '${GUEST_A_USERNAME}' status: ${RESP}" 85 | fi 86 | 87 | RESP=$(curl -s -X POST -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_A_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 88 | if [ "${RESP}" == 'Status: REVOKED' ]; then 89 | echo "[OK] Test signing key when revoked" 90 | else 91 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key when revoked: ${RESP}" 92 | fi 93 | 94 | RESP=$(curl -s -X POST -d "username=${GUEST_A_USERNAME}&realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 95 | if [ "${RESP}" == 'Error : (username, realname, pubkey) triple mismatch.' ]; then 96 | echo "[OK] Test signing key when revoked with wrong realname" 97 | else 98 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key when revoked with wrong realname: ${RESP}" 99 | fi 100 | 101 | RESP=$(curl -s -X POST -d "username=${GUEST_A_USERNAME}&realname=${GUEST_A_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_A_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 102 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 103 | echo "[OK] Test signing key when revoked with invalid credentials" 104 | else 105 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key when revoked with invalid credentials : ${RESP}" 106 | fi 107 | 108 | ########################## 109 | ## ADMIN DELETE GUEST A ## 110 | ########################## 111 | RESP=$(curl -s -X DELETE -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 112 | if [ "${RESP}" == 'OK' ]; then 113 | echo "[OK] Test delete '${GUEST_A_USERNAME}'" 114 | else 115 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test delete '${GUEST_A_USERNAME}': ${RESP}" 116 | fi 117 | 118 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_A_USERNAME}") 119 | if [ "${RESP}" == "User does not exists." ]; then 120 | echo "[OK] Test admin active unknown user" 121 | else 122 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin active unknown user : ${RESP}" 123 | fi 124 | 125 | RESP=$(curl -s -X POST "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}") 126 | if [ "${RESP}" == "Error: No realname option given." ]; then 127 | echo "[OK] Test admin active '${GUEST_B_USERNAME}' status without realname,password" 128 | else 129 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin active '${GUEST_B_USERNAME}' status without realname,password: ${RESP}" 130 | fi 131 | 132 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}") 133 | if [ "${RESP}" == "Error: No password option given." ]; then 134 | echo "[OK] Test admin active '${GUEST_B_USERNAME}' status without password" 135 | else 136 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin active '${GUEST_B_USERNAME}' status without password: ${RESP}" 137 | fi 138 | 139 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${GUEST_A_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}") 140 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 141 | echo "[OK] Test admin active '${GUEST_B_USERNAME}' status with invalid credentials" 142 | else 143 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin active '${GUEST_B_USERNAME}' status with invalid credentials: ${RESP}" 144 | fi 145 | 146 | RESP=$(curl -s -X POST -d "realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}") 147 | if [ "${RESP}" == "Error: Not authorized." ]; then 148 | echo "[OK] Test admin active '${GUEST_B_USERNAME}' status with unauthorized user" 149 | else 150 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin active '${GUEST_B_USERNAME}' status with unauthorized user: ${RESP}" 151 | fi 152 | 153 | RESP=$(curl -s -X POST -d "status=true&realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}" | jq .status) 154 | if [ "${RESP}" == '"PENDING"' ]; then 155 | echo "[OK] Test admin verify '${GUEST_B_USERNAME}' status" 156 | else 157 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin verify '${GUEST_B_USERNAME}' status : ${RESP}" 158 | fi 159 | 160 | ############################ 161 | ## ADMIN ACTIVATE GUEST B ## 162 | ############################ 163 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}") 164 | if [ "${RESP}" == "Active user=${GUEST_B_USERNAME}. SSH Key active but need to be signed." ]; then 165 | echo "[OK] Test admin active ${GUEST_B_USERNAME}" 166 | else 167 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin active ${GUEST_B_USERNAME} : ${RESP}" 168 | fi 169 | 170 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}") 171 | if [ "${RESP}" == "user=${GUEST_B_USERNAME} already active. Nothing done." ]; then 172 | echo "[OK] Test admin re-active ${GUEST_B_USERNAME}" 173 | else 174 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin re-active ${GUEST_B_USERNAME} : ${RESP}" 175 | fi 176 | 177 | ############################ 178 | ## ADMIN ACTIVATE GUEST C ## 179 | ############################ 180 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_C_USERNAME}") 181 | if [ "${RESP}" == "Active user=${GUEST_C_USERNAME}. SSH Key active but need to be signed." ]; then 182 | echo "[OK] Test admin active ${GUEST_C_USERNAME}" 183 | else 184 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin active ${GUEST_C_USERNAME} : ${RESP}" 185 | fi 186 | 187 | ############################# 188 | ## ADMIN ACTIVATE SYSADMIN ## 189 | ############################# 190 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${SYSADMIN_USERNAME}") 191 | if [ "${RESP}" == "Active user=${SYSADMIN_USERNAME}. SSH Key active but need to be signed." ]; then 192 | echo "[OK] Test admin active ${SYSADMIN_USERNAME}" 193 | else 194 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test admin active ${SYSADMIN_USERNAME} : ${RESP}" 195 | fi 196 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | Copyright 2017 Nicolas BEGUIER 179 | 180 | Licensed under the Apache License, Version 2.0 (the "License"); 181 | you may not use this file except in compliance with the License. 182 | You may obtain a copy of the License at 183 | 184 | http://www.apache.org/licenses/LICENSE-2.0 185 | 186 | Unless required by applicable law or agreed to in writing, software 187 | distributed under the License is distributed on an "AS IS" BASIS, 188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 189 | See the License for the specific language governing permissions and 190 | limitations under the License. 191 | -------------------------------------------------------------------------------- /tests/test_principals.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # shellcheck disable=SC2128 3 | 4 | RESP=$(curl -s -X POST "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "add=test-single") 5 | if [ "${RESP}" == "Error: No realname option given." ]; then 6 | echo "[OK] Test add principal without realname,password" 7 | else 8 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add principal without realname,password: ${RESP}" 9 | fi 10 | 11 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "add=test-single") 12 | if [ "${RESP}" == "Error: No password option given." ]; then 13 | echo "[OK] Test add principal without password" 14 | else 15 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add principal without password: ${RESP}" 16 | fi 17 | 18 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${GUEST_B_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "add=test-single") 19 | if [[ "${RESP}" == *"'desc': 'Invalid credentials'"* ]]; then 20 | echo "[OK] Test add principal with invalid credentials" 21 | else 22 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add principal with invalid credentials: ${RESP}" 23 | fi 24 | 25 | RESP=$(curl -s -X POST -d "realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "add=test-single") 26 | if [ "${RESP}" == "Error: Not authorized." ]; then 27 | echo "[OK] Test add principal with unauthorized user" 28 | else 29 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add principal with unauthorized user: ${RESP}" 30 | fi 31 | 32 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/unknown/principals -d "add=test-single") 33 | if [ "${RESP}" == "ERROR: unknown doesn't exist" ]; then 34 | echo "[OK] Test add principal 'test-single' to unknown user" 35 | else 36 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add principal 'test-single' to unknown user : ${RESP}" 37 | fi 38 | 39 | ################################# 40 | ## ADMIN ADD PRINCIPAL GUEST B ## 41 | ################################# 42 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "add=test-single") 43 | if [ "${RESP}" == "OK: ${GUEST_B_USERNAME} principals are '${GUEST_B_USERNAME},test-single,guest-everywhere'" ]; then 44 | echo "[OK] Test add principal 'test-single' to ${GUEST_B_USERNAME}" 45 | else 46 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add principal 'test-single' to ${GUEST_B_USERNAME} : ${RESP}" 47 | fi 48 | 49 | RESP=$(curl -s -X POST -d "username=${GUEST_B_USERNAME}&realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_B_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 50 | echo "${RESP}" > /tmp/test-cert 51 | OUTPUT=$(echo $(echo -n "$(ssh-keygen -L -f /tmp/test-cert 2>&1)")) 52 | if [[ "${OUTPUT}" == *"Principals: ${GUEST_B_USERNAME} test-single guest-everywhere Critical"* ]]; then 53 | echo "[OK] Test signing key with updated principals" 54 | else 55 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key with updated principals: ${OUTPUT}" 56 | fi 57 | rm -f /tmp/test-cert 58 | 59 | RESP=$(curl -s -X POST -d "username=${GUEST_B_USERNAME}&realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_B_ALT_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 60 | echo "${RESP}" > /tmp/test-cert 61 | OUTPUT=$(echo $(echo -n "$(ssh-keygen -L -f /tmp/test-cert 2>&1)")) 62 | if [[ "${OUTPUT}" == *"Principals: ${GUEST_B_USERNAME} test-single guest-everywhere Critical"* ]]; then 63 | echo "[OK] Test signing key with altered public key" 64 | else 65 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key with altered public key: ${OUTPUT}" 66 | fi 67 | rm -f /tmp/test-cert 68 | 69 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "add=test-single") 70 | if [ "${RESP}" == "OK: ${GUEST_B_USERNAME} principals are '${GUEST_B_USERNAME},test-single,guest-everywhere'" ]; then 71 | echo "[OK] Test add duplicate principal 'test-single' to ${GUEST_B_USERNAME}" 72 | else 73 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add duplicate principal 'test-single' to ${GUEST_B_USERNAME} : ${RESP}" 74 | fi 75 | 76 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "remove=test-single") 77 | if [ "${RESP}" == "OK: ${GUEST_B_USERNAME} principals are '${GUEST_B_USERNAME},guest-everywhere'" ]; then 78 | echo "[OK] Test remove principal 'test-single' to ${GUEST_B_USERNAME} which doesn't exists" 79 | else 80 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test remove principal 'test-single' to ${GUEST_B_USERNAME} which doesn't exists : ${RESP}" 81 | fi 82 | 83 | #################################### 84 | ## ADMIN REMOVE PRINCIPAL GUEST B ## 85 | #################################### 86 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "remove=test-single") 87 | if [ "${RESP}" == "OK: ${GUEST_B_USERNAME} principals are '${GUEST_B_USERNAME},guest-everywhere'" ]; then 88 | echo "[OK] Test remove principal 'test-single' to ${GUEST_B_USERNAME}" 89 | else 90 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test remove principal 'test-single' to ${GUEST_B_USERNAME} : ${RESP}" 91 | fi 92 | 93 | RESP=$(curl -s -X POST -d "username=${GUEST_B_USERNAME}&realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_B_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 94 | echo "${RESP}" > /tmp/test-cert 95 | OUTPUT=$(echo $(echo -n "$(ssh-keygen -L -f /tmp/test-cert 2>&1)")) 96 | if [[ "${OUTPUT}" == *"Principals: ${GUEST_B_USERNAME} guest-everywhere Critical"* ]]; then 97 | echo "[OK] Test signing key with updated principals" 98 | else 99 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key with updated principals: ${OUTPUT}" 100 | fi 101 | rm -f /tmp/test-cert 102 | 103 | ################################### 104 | ## ADMIN PURGE PRINCIPAL GUEST B ## 105 | ################################### 106 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "purge=true") 107 | if [ "${RESP}" == "OK: ${GUEST_B_USERNAME} principals are '${GUEST_B_USERNAME},guest-everywhere'" ]; then 108 | echo "[OK] Test purge principals to ${GUEST_B_USERNAME}" 109 | else 110 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test purge principals to ${GUEST_B_USERNAME} : ${RESP}" 111 | fi 112 | 113 | RESP=$(curl -s -X POST -d "username=${GUEST_B_USERNAME}&realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_B_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 114 | echo "${RESP}" > /tmp/test-cert 115 | OUTPUT=$(echo $(echo -n "$(ssh-keygen -L -f /tmp/test-cert 2>&1)")) 116 | if [[ "${OUTPUT}" == *"Principals: ${GUEST_B_USERNAME} guest-everywhere Critical"* ]]; then 117 | echo "[OK] Test signing key with updated principals" 118 | else 119 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key with updated principals: ${OUTPUT}" 120 | fi 121 | rm -f /tmp/test-cert 122 | 123 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "add=test-multiple-a,test-multiple-b") 124 | if [ "${RESP}" == "OK: ${GUEST_B_USERNAME} principals are '${GUEST_B_USERNAME},test-multiple-a,test-multiple-b,guest-everywhere'" ]; then 125 | echo "[OK] Test add principals 'test-multiple-a,test-multiple-b' to ${GUEST_B_USERNAME}" 126 | else 127 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add principals 'test-multiple-a,test-multiple-b' to ${GUEST_B_USERNAME} : ${RESP}" 128 | fi 129 | 130 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "remove=test-multiple-a,${BADTEXT}") 131 | if [ "${RESP}" == "Error: invalid principals." ]; then 132 | echo "[OK] Test remove principals 'test-multiple-a,${BADTEXT}' to ${GUEST_B_USERNAME}" 133 | else 134 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test remove principals 'test-multiple-a,test-multiple-b' to ${GUEST_B_USERNAME} : ${RESP}" 135 | fi 136 | 137 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "remove=test-multiple-a,test-multiple-b") 138 | if [ "${RESP}" == "OK: ${GUEST_B_USERNAME} principals are '${GUEST_B_USERNAME},guest-everywhere'" ]; then 139 | echo "[OK] Test remove principals 'test-multiple-a,test-multiple-b' to ${GUEST_B_USERNAME}" 140 | else 141 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test remove principals 'test-multiple-a,test-multiple-b' to ${GUEST_B_USERNAME} : ${RESP}" 142 | fi 143 | 144 | #################################### 145 | ## ADMIN UPDATE PRINCIPAL GUEST B ## 146 | #################################### 147 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "update=test-multiple-c,test-multiple-${GUEST_B_USERNAME}") 148 | if [ "${RESP}" == "OK: ${GUEST_B_USERNAME} principals are 'test-multiple-c,test-multiple-${GUEST_B_USERNAME},guest-everywhere'" ]; then 149 | echo "[OK] Test update principals 'test-multiple-c,test-multiple-${GUEST_B_USERNAME}' to ${GUEST_B_USERNAME}" 150 | else 151 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test update principals 'test-multiple-c,test-multiple-${GUEST_B_USERNAME}' to ${GUEST_B_USERNAME} : ${RESP}" 152 | fi 153 | 154 | RESP=$(curl -s -X POST -d "username=${GUEST_B_USERNAME}&realname=${GUEST_B_REALNAME}&password=${GUEST_B_PASSWORD}&pubkey=${GUEST_B_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 155 | echo "${RESP}" > /tmp/test-cert 156 | OUTPUT=$(echo $(echo -n "$(ssh-keygen -L -f /tmp/test-cert 2>&1)")) 157 | if [[ "${OUTPUT}" == *"Principals: test-multiple-c test-multiple-${GUEST_B_USERNAME} guest-everywhere Critical"* ]]; then 158 | echo "[OK] Test signing key with updated principals" 159 | else 160 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing key with updated principals: ${OUTPUT}" 161 | fi 162 | rm -f /tmp/test-cert 163 | 164 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "update=test-multiple-c,test-multiple-c,test-multiple-${GUEST_B_USERNAME}") 165 | if [ "${RESP}" == "OK: ${GUEST_B_USERNAME} principals are 'test-multiple-c,test-multiple-${GUEST_B_USERNAME},guest-everywhere'" ]; then 166 | echo "[OK] Test update with duplicate principals 'test-multiple-c,test-multiple-${GUEST_B_USERNAME}' to ${GUEST_B_USERNAME}" 167 | else 168 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test update with duplicate principals 'test-multiple-c,test-multiple-${GUEST_B_USERNAME}' to ${GUEST_B_USERNAME} : ${RESP}" 169 | fi 170 | 171 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${GUEST_B_USERNAME}"/principals -d "unknown=action") 172 | if [ "${RESP}" == "[ERROR] Unknown action" ]; then 173 | echo "[OK] Test unknown action" 174 | else 175 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test unknown action : ${RESP}" 176 | fi 177 | 178 | RESP=$(curl -s -X POST -d "username=${SYSADMIN_USERNAME}&realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}&pubkey=${SYSADMIN_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 179 | echo "${RESP}" > /tmp/test-cert 180 | OUTPUT=$(echo $(echo -n "$(ssh-keygen -L -f /tmp/test-cert 2>&1)")) 181 | if [[ "${OUTPUT}" == *"Principals: ${SYSADMIN_USERNAME} root-everywhere guest-everywhere Critical"* ]]; then 182 | echo "[OK] Test signing sysadmin key" 183 | else 184 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing sysadmin key: ${OUTPUT}" 185 | fi 186 | rm -f /tmp/test-cert 187 | 188 | RESP=$(curl -s -X POST -d "realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}" "${CASSH_SERVER_URL}"/admin/"${SYSADMIN_USERNAME}"/principals -d "add=root-everywhere") 189 | if [ "${RESP}" == "OK: ${SYSADMIN_USERNAME} principals are '${SYSADMIN_USERNAME},root-everywhere,guest-everywhere'" ]; then 190 | echo "[OK] Test add duplicate principal 'root-everywhere' to ${SYSADMIN_USERNAME}" 191 | else 192 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test add duplicate principal 'root-everywhere' to ${SYSADMIN_USERNAME} : ${RESP}" 193 | fi 194 | 195 | RESP=$(curl -s -X POST -d "username=${SYSADMIN_USERNAME}&realname=${SYSADMIN_REALNAME}&password=${SYSADMIN_PASSWORD}&pubkey=${SYSADMIN_PUB_KEY}" "${CASSH_SERVER_URL}"/client) 196 | echo "${RESP}" > /tmp/test-cert 197 | OUTPUT=$(echo $(echo -n "$(ssh-keygen -L -f /tmp/test-cert 2>&1)")) 198 | if [[ "${OUTPUT}" == *"Principals: ${SYSADMIN_USERNAME} root-everywhere guest-everywhere Critical"* ]]; then 199 | echo "[OK] Test signing sysadmin key without duplicates" 200 | else 201 | echo "[FAIL ${BASH_SOURCE}:+${LINENO}] Test signing sysadmin key without duplicates: ${OUTPUT}" 202 | fi 203 | rm -f /tmp/test-cert 204 | -------------------------------------------------------------------------------- /src/client/cassh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #-*- coding: utf-8 -*- 3 | """ 4 | CASSH CLI 5 | 6 | Copyright 2017-2025 Nicolas BEGUIER 7 | Licensed under the Apache License, Version 2.0 8 | Written by Nicolas BEGUIER (nicolas_beguier@hotmail.com) 9 | 10 | """ 11 | 12 | # Standard library imports 13 | from __future__ import print_function 14 | from argparse import ArgumentParser 15 | from datetime import datetime 16 | from getpass import getpass 17 | from json import dumps, loads 18 | from os import chmod, getenv 19 | from os.path import isfile 20 | from re import compile as re_compile 21 | from shutil import copyfile 22 | import sys 23 | 24 | # Third party library imports 25 | from configparser import ConfigParser, NoOptionError, NoSectionError 26 | from requests import Session 27 | from requests.exceptions import ConnectionError, ReadTimeout 28 | 29 | # Debug 30 | # from pdb import set_trace as st 31 | 32 | VERSION = '%(prog)s 1.8.1' 33 | 34 | PATTERN_USERNAME = re_compile("^([a-z]+)$") 35 | 36 | def print_result(result): 37 | """ Display result """ 38 | date_formatted = datetime.strptime(result['expiration'], '%Y-%m-%d %H:%M:%S') 39 | is_expired = date_formatted < datetime.now() 40 | if result['status'] == 'ACTIVE': 41 | if is_expired and date_formatted.year == 1970: 42 | result['status'] = 'NEVER SIGNED' 43 | elif is_expired: 44 | result['status'] = 'EXPIRED' 45 | else: 46 | result['status'] = 'SIGNED' 47 | print(dumps(result, indent=4, sort_keys=True)) 48 | 49 | def read_conf(conf_path): 50 | """ 51 | Read CASSH configuration file and return metadata. 52 | """ 53 | config = ConfigParser() 54 | config.read(conf_path) 55 | user_metadata = {} 56 | try: 57 | user_metadata['name'] = config.get('user', 'name') 58 | user_metadata['key_path'] = config.get('user', 'key_path')\ 59 | .replace('~', getenv('HOME')) 60 | user_metadata['key_signed_path'] = config.get('user', 'key_signed_path')\ 61 | .replace('~', getenv('HOME')) 62 | user_metadata['url'] = config.get('user', 'url') 63 | except NoOptionError as error_msg: 64 | print('Can\'t read configuration file...') 65 | print(error_msg) 66 | sys.exit(1) 67 | 68 | if not config.has_option('user', 'timeout'): 69 | user_metadata['timeout'] = 2 70 | else: 71 | user_metadata['timeout'] = int(config.get('user', 'timeout')) 72 | 73 | if not config.has_option('user', 'verify'): 74 | user_metadata['verify'] = True 75 | else: 76 | user_metadata['verify'] = bool(config.get('user', 'verify') != 'False') 77 | 78 | if user_metadata['key_path'] == user_metadata['key_signed_path']: 79 | print('You should put a different path for key_path and key_signed_path.') 80 | sys.exit(1) 81 | 82 | try: 83 | user_metadata['auth'] = 'ldap' 84 | user_metadata['realname'] = config.get('ldap', 'realname') 85 | except NoOptionError as error_msg: 86 | print('Can\'t read configuration file...') 87 | print(error_msg) 88 | sys.exit(1) 89 | except NoSectionError: 90 | user_metadata['auth'] = None 91 | user_metadata['realname'] = None 92 | 93 | try: 94 | user_metadata['ldap_enable'] = bool(config.get('ldap', 'enable') != 'False') 95 | except NoOptionError: 96 | user_metadata['ldap_enable'] = True 97 | except NoSectionError: 98 | user_metadata['ldap_enable'] = False 99 | 100 | if not isfile(user_metadata['key_path']): 101 | print('File %s doesn\'t exists' % user_metadata['key_path']) 102 | sys.exit(1) 103 | 104 | return user_metadata 105 | 106 | def get_set_value(arguments): 107 | """ 108 | Returns the chosen value of 'set' action 109 | """ 110 | if arguments.add_principals: 111 | return 'add', arguments.add_principals 112 | if arguments.remove_principals: 113 | return 'remove', arguments.remove_principals 114 | if arguments.update_principals: 115 | return 'update', arguments.update_principals 116 | if arguments.purge_principals: 117 | return 'purge', arguments.purge_principals 118 | return 'deprecated', arguments.set 119 | 120 | class CASSH(object): 121 | """ 122 | Main CASSH class. 123 | """ 124 | def __init__(self, user_metadata): 125 | """ 126 | Init file. 127 | """ 128 | self.session = Session() 129 | self.auth = user_metadata['auth'] 130 | self.key_path = user_metadata['key_path'] 131 | self.name = user_metadata['name'] 132 | self.realname = user_metadata['realname'] 133 | self.user_metadata = user_metadata 134 | self.user_metadata['headers'] = { 135 | 'User-Agent': 'CASSH-CLIENT v%s' % VERSION.split(' ')[1], 136 | 'CLIENT_VERSION': VERSION.split(' ')[1], 137 | } 138 | 139 | ######################## 140 | ## REQUESTS FUNCTIONS ## 141 | ######################## 142 | 143 | def delete(self, uri, data): 144 | """ 145 | Rebuilt DELETE function for CASSH purpose 146 | """ 147 | try: 148 | req = self.session.delete(self.user_metadata['url'] + uri, 149 | data=data, 150 | headers=self.user_metadata['headers'], 151 | timeout=self.user_metadata['timeout'], 152 | verify=self.user_metadata['verify']) 153 | except ConnectionError: 154 | print('Connection error : %s' % self.user_metadata['url']) 155 | sys.exit(1) 156 | except ReadTimeout as err_msg: 157 | print('Timeout : %s' % err_msg) 158 | sys.exit(1) 159 | return req 160 | 161 | def get(self, uri): 162 | """ 163 | Rebuilt GET function for CASSH purpose 164 | """ 165 | try: 166 | req = self.session.get(self.user_metadata['url'] + uri, 167 | headers=self.user_metadata['headers'], 168 | timeout=self.user_metadata['timeout'], 169 | verify=self.user_metadata['verify']) 170 | except ConnectionError: 171 | print('Connection error : %s' % self.user_metadata['url']) 172 | sys.exit(1) 173 | except ReadTimeout as err_msg: 174 | print('Timeout : %s' % err_msg) 175 | sys.exit(1) 176 | return req 177 | 178 | def patch(self, uri, data): 179 | """ 180 | Rebuilt PATCH function for CASSH purpose 181 | """ 182 | try: 183 | req = self.session.patch(self.user_metadata['url'] + uri, 184 | data=data, 185 | headers=self.user_metadata['headers'], 186 | timeout=self.user_metadata['timeout'], 187 | verify=self.user_metadata['verify']) 188 | except ConnectionError: 189 | print('Connection error : %s' % self.user_metadata['url']) 190 | sys.exit(1) 191 | except ReadTimeout as err_msg: 192 | print('Timeout : %s' % err_msg) 193 | sys.exit(1) 194 | return req 195 | 196 | def post(self, uri, data): 197 | """ 198 | Rebuilt POST function for CASSH purpose 199 | """ 200 | try: 201 | req = self.session.post(self.user_metadata['url'] + uri, 202 | data=data, 203 | headers=self.user_metadata['headers'], 204 | timeout=self.user_metadata['timeout'], 205 | verify=self.user_metadata['verify']) 206 | except ConnectionError: 207 | print('Connection error : %s' % self.user_metadata['url']) 208 | sys.exit(1) 209 | except ReadTimeout as err_msg: 210 | print('Timeout : %s' % err_msg) 211 | sys.exit(1) 212 | return req 213 | 214 | def put(self, uri, data): 215 | """ 216 | Rebuilt PUT function for CASSH purpose 217 | """ 218 | try: 219 | req = self.session.put(self.user_metadata['url'] + uri, 220 | data=data, 221 | headers=self.user_metadata['headers'], 222 | timeout=self.user_metadata['timeout'], 223 | verify=self.user_metadata['verify']) 224 | except ConnectionError: 225 | print('Connection error : %s' % self.user_metadata['url']) 226 | sys.exit(1) 227 | except ReadTimeout as err_msg: 228 | print('Timeout : %s' % err_msg) 229 | sys.exit(1) 230 | return req 231 | 232 | ######################## 233 | ## MAIN FUNCTIONS ## 234 | ######################## 235 | 236 | def get_data(self, prefix=None): 237 | """ 238 | Return data for a POST request. 239 | """ 240 | data = {} 241 | passwd_message = 'Please type your LDAP password (user=%s): ' % self.realname 242 | if self.user_metadata['ldap_enable']: 243 | data.update({'password': getpass(passwd_message)}) 244 | if self.auth == 'ldap': 245 | data.update({'realname': self.realname}) 246 | if prefix is not None: 247 | data.update(prefix) 248 | return data 249 | 250 | def admin(self, username, action, set_value, search_value): 251 | """ 252 | Admin CLI 253 | """ 254 | if PATTERN_USERNAME.match(username) is None: 255 | print('Username not valid') 256 | sys.exit(1) 257 | payload = self.get_data() 258 | if action == 'revoke': 259 | payload.update({'revoke': True}) 260 | req = self.post('/admin/' + username, payload) 261 | elif action == 'active': 262 | req = self.post('/admin/' + username, payload) 263 | elif action == 'delete': 264 | req = self.delete('/admin/' + username, payload) 265 | elif action == 'set': 266 | if set_value[0] == 'deprecated': 267 | set_value_dict = {} 268 | set_value_dict[set_value[1].split('=')[0]] = set_value[1].split('=')[1] 269 | payload.update(set_value_dict) 270 | req = self.patch('/admin/' + username, payload) 271 | else: 272 | payload.update({set_value[0]: set_value[1]}) 273 | req = self.post('/admin/' + username + '/principals', payload) 274 | elif action == 'search': 275 | payload.update({'filter': search_value}) 276 | req = self.post('/admin/all/principals/search', payload) 277 | elif action == 'status': 278 | payload.update({'status': True}) 279 | req = self.post('/admin/' + username, payload) 280 | try: 281 | result = loads(req.text) 282 | except ValueError: 283 | print(req.text) 284 | return 285 | if result == {}: 286 | print(dumps(result, indent=4, sort_keys=True)) 287 | return 288 | if username == 'all': 289 | for user in result: 290 | print_result(result[user]) 291 | return 292 | print_result(result) 293 | return 294 | else: 295 | print('Action should be : active, delete, revoke, set, search, status') 296 | sys.exit(1) 297 | print(req.text) 298 | 299 | def add(self): 300 | """ 301 | Add a public key. 302 | """ 303 | payload = self.get_data() 304 | with open('%s.pub' % self.key_path, 'r') as pubkey: 305 | payload.update({'pubkey': pubkey.read()}) 306 | payload.update({'username': self.name}) 307 | req = self.put('/client', payload) 308 | print(req.text) 309 | 310 | def sign(self, do_write_on_disk, force=False): 311 | """ 312 | Sign a public key. 313 | """ 314 | payload = self.get_data() 315 | with open('%s.pub' % self.key_path, 'r') as pubkey: 316 | payload.update({'pubkey': pubkey.read()}) 317 | payload.update({'username': self.name}) 318 | if force: 319 | payload.update({'admin_force': True}) 320 | req = self.post('/client', payload) 321 | if not 'ssh-' in req.text and not 'ecdsa-' in req.text: 322 | print(req.text) 323 | sys.exit(1) 324 | if do_write_on_disk: 325 | key_signed_path = self.user_metadata['key_signed_path'] 326 | copyfile(self.key_path, key_signed_path) 327 | chmod(key_signed_path, 0o600) 328 | with open('%s.pub' % key_signed_path, 'w+') as pubkey_signed: 329 | pubkey_signed.write(req.text) 330 | print('Public key successfuly signed') 331 | else: 332 | print(req.text) 333 | 334 | def status(self): 335 | """ 336 | Get status of public key. 337 | """ 338 | payload = self.get_data() 339 | req = self.post('/client/status', payload) 340 | try: 341 | result = loads(req.text) 342 | except ValueError: 343 | print(req.text) 344 | return 345 | if result == {}: 346 | print(dumps(result, indent=4, sort_keys=True)) 347 | return 348 | print_result(result) 349 | 350 | def get_ca(self): 351 | """ 352 | Get CA public key. 353 | """ 354 | req = self.get('/ca') 355 | print(req.text) 356 | 357 | def get_krl(self): 358 | """ 359 | Get CA KRL. 360 | """ 361 | req = self.get('/krl') 362 | print(req.text) 363 | 364 | 365 | if __name__ == '__main__': 366 | 367 | PARSER = ArgumentParser() 368 | 369 | SUBPARSERS = PARSER.add_subparsers(help='commands') 370 | 371 | PARSER.add_argument('--version', action='version', version=VERSION) 372 | 373 | # ADMIN Arguments 374 | ADMIN_PARSER = SUBPARSERS.add_parser('admin',\ 375 | help='Administrator command : active, delete, revoke, set, search, status keys') 376 | ADMIN_PARSER.add_argument('username', action='store',\ 377 | help='Username of client\'s key, if username is \'all\' status return all users') 378 | ADMIN_PARSER.add_argument('action', action='store',\ 379 | help='Choice between : active, delete, revoke, set, search, status keys') 380 | ADMIN_PARSER.add_argument('-s', '--set', action='store',\ 381 | help='CAUTION: Set value of a user.') 382 | ADMIN_PARSER.add_argument('--add-principals', action='store',\ 383 | help='Add a list of principals to a user, should be separated by comma without spaces.') 384 | ADMIN_PARSER.add_argument('--remove-principals', action='store',\ 385 | help='Remove a list of principals to a user, should be separated by comma without spaces.') 386 | ADMIN_PARSER.add_argument('--purge-principals', action='store_true',\ 387 | help='Purge all principals to a user.') 388 | ADMIN_PARSER.add_argument('--update-principals', action='store',\ 389 | help='Update all principals to a user by the given principals, \ 390 | should be separated by comma without spaces.') 391 | ADMIN_PARSER.add_argument('--principals-filter', action='store',\ 392 | help='Look for users by the given principals filter, \ 393 | should be separated by comma without spaces.') 394 | 395 | # ADD Arguments 396 | ADD_PARSER = SUBPARSERS.add_parser('add', help='Add a key to remote ssh ca server.') 397 | 398 | # SIGN Arguments 399 | SIGN_PARSER = SUBPARSERS.add_parser('sign', help='Sign its key by remote ssh ca server.') 400 | SIGN_PARSER.add_argument('-d', '--display-only', action='store_true',\ 401 | help='Display key in shell only.') 402 | SIGN_PARSER.add_argument('-f', '--force', action='store_true',\ 403 | help='Admin can force signature if server enable it.') 404 | 405 | # STATUS Arguments 406 | STATUS_PARSER = SUBPARSERS.add_parser('status',\ 407 | help='Display key current status on remote ssh ca server.') 408 | 409 | # CA Arguments 410 | CA_PARSER = SUBPARSERS.add_parser('ca',\ 411 | help='Display CA public key.') 412 | 413 | # KRL Arguments 414 | KRL_PARSER = SUBPARSERS.add_parser('krl',\ 415 | help='Display CA KRL.') 416 | 417 | ARGS = PARSER.parse_args() 418 | 419 | CONF_FILE = '%s/.cassh' % getenv('HOME') 420 | 421 | if not isfile(CONF_FILE): 422 | print('Config file missing : %s' % CONF_FILE) 423 | print('Example:') 424 | print('[user]') 425 | print('# name : this is the username you will use to log on every server') 426 | print('name = user') 427 | print('# key_path: This key path won\'t be used to log in, a copy will be made.') 428 | print('# We assume that `${key_path}` exists and `${key_path}.pub` as well.') 429 | print('# WARNING: Never delete these keys') 430 | print('key_path = ~/.ssh/id_rsa') 431 | print('# key_signed_path: Every signed key via cassh will be put in this path.') 432 | print('# At every sign, `${key_signed_path}` and `${key_signed_path}.pub` will be created') 433 | print('key_signed_path = ~/.ssh/id_rsa-cert') 434 | print('# url : URL of cassh server-side backend.') 435 | print('url = https://cassh.net') 436 | print('# [OPTIONNAL] timeout : requests timeout parameter in second. (timeout=2)') 437 | print('# timeout = 2') 438 | print('# [OPTIONNAL] verify : verifies SSL certificates for HTTPS requests. (verify=True)') 439 | print('# verify = True') 440 | print('') 441 | print('[ldap]') 442 | print('# realname : this is the LDAP/AD login user') 443 | print('realname = ursula.ser@domain.fr') 444 | print('enable = True') 445 | sys.exit(1) 446 | 447 | CLIENT = CASSH(read_conf(CONF_FILE)) 448 | 449 | if len(sys.argv) == 1: 450 | PARSER.print_help() 451 | sys.exit(1) 452 | 453 | if sys.argv[1] == 'add': 454 | CLIENT.add() 455 | elif sys.argv[1] == 'sign': 456 | CLIENT.sign(not ARGS.display_only, force=ARGS.force) 457 | elif sys.argv[1] == 'status': 458 | CLIENT.status() 459 | elif sys.argv[1] == 'ca': 460 | CLIENT.get_ca() 461 | elif sys.argv[1] == 'krl': 462 | CLIENT.get_krl() 463 | elif sys.argv[1] == 'admin': 464 | CLIENT.admin( 465 | ARGS.username, 466 | ARGS.action, 467 | get_set_value(ARGS), 468 | ARGS.principals_filter) 469 | else: 470 | PARSER.print_help() 471 | sys.exit(1) 472 | 473 | sys.exit(0) 474 | -------------------------------------------------------------------------------- /src/server/lib/tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | tools lib 4 | 5 | Copyright 2017-2025 Nicolas BEGUIER 6 | Licensed under the Apache License, Version 2.0 7 | Written by Nicolas BEGUIER (nicolas_beguier@hotmail.com) 8 | 9 | """ 10 | # pylint: disable=too-many-branches,too-many-statements,too-many-return-statements 11 | # pylint: disable=broad-except,too-many-arguments,no-name-in-module 12 | 13 | from argparse import ArgumentParser 14 | from datetime import datetime, timedelta 15 | from glob import glob 16 | import json 17 | from random import choice 18 | from shutil import copyfile 19 | from string import ascii_lowercase 20 | from tempfile import NamedTemporaryFile 21 | from os.path import isfile 22 | from os import remove 23 | import sys 24 | from time import time 25 | from urllib.parse import unquote_plus 26 | 27 | # Third party library imports 28 | from configparser import ConfigParser, NoOptionError 29 | from ldap import initialize, NO_SUCH_OBJECT, SCOPE_SUBTREE 30 | from psycopg2 import connect, OperationalError, ProgrammingError 31 | from requests import Session 32 | from requests.exceptions import ConnectionError as req_ConnectionError 33 | from web import data, ctx, header 34 | 35 | # Own library 36 | from ssh_utils import Authority 37 | import lib.constants as constants 38 | 39 | # DEBUG 40 | # from pdb import set_trace as st 41 | 42 | def loadconfig(version='Unknown'): 43 | """ 44 | Config loader 45 | """ 46 | parser = ArgumentParser() 47 | parser.add_argument('-c', '--config', action='store', help='Configuration file') 48 | parser.add_argument( 49 | '-v', '--verbose', action='store_true', default=False, 50 | help='Add verbosity') 51 | args = parser.parse_args() 52 | 53 | if not args.config: 54 | parser.error('--config argument is required !') 55 | 56 | config = ConfigParser() 57 | config.read(args.config) 58 | server_opts = {} 59 | server_opts['ca'] = config.get('main', 'ca') 60 | server_opts['krl'] = config.get('main', 'krl') 61 | server_opts['port'] = config.get('main', 'port') 62 | 63 | try: 64 | server_opts['admin_db_failover'] = config.get('main', 'admin_db_failover') 65 | except NoOptionError: 66 | server_opts['admin_db_failover'] = False 67 | server_opts['ldap'] = False 68 | server_opts['ssl'] = False 69 | server_opts['ldap_mapping'] = dict() 70 | 71 | if config.has_section('postgres'): 72 | try: 73 | server_opts['db_host'] = config.get('postgres', 'host') 74 | server_opts['db_name'] = config.get('postgres', 'dbname') 75 | server_opts['db_user'] = config.get('postgres', 'user') 76 | server_opts['db_password'] = config.get('postgres', 'password') 77 | except NoOptionError: 78 | if args.verbose: 79 | print('Option reading error (postgres).') 80 | sys.exit(1) 81 | 82 | if config.has_section('ldap'): 83 | 84 | # Future deprecation of this block 85 | # START 86 | try: 87 | config.get('ldap', 'filterstr') 88 | print('WARNING: "filterstr" is deprecated, use "filter_realname_key" instead') 89 | except NoOptionError: 90 | pass 91 | # END 92 | 93 | try: 94 | server_opts['ldap'] = True 95 | server_opts['ldap_host'] = config.get('ldap', 'host') 96 | server_opts['ldap_bind_dn'] = config.get('ldap', 'bind_dn') 97 | server_opts['ldap_username'] = config.get('ldap', 'username') 98 | server_opts['ldap_password'] = config.get('ldap', 'password') 99 | server_opts['ldap_admin_cn'] = config.get('ldap', 'admin_cn') 100 | server_opts['ldap_filter_realname_key'] = config.get('ldap', 'filter_realname_key') 101 | server_opts['ldap_protocol'] = config.get('ldap', 'protocol', fallback='ldap') 102 | if server_opts['ldap_protocol'] not in ['ldap', 'ldaps', 'starttls']: 103 | print('Option reading error (ldap): %s not in ["ldap", "ldaps", "starttls"]' \ 104 | % (server_opts['ldap_protocol'])) 105 | sys.exit(1) 106 | except NoOptionError: 107 | if args.verbose: 108 | print('Option reading error (ldap).') 109 | sys.exit(1) 110 | try: 111 | server_opts['ldap_username_prefix'] = config.get('ldap', 'username_prefix') 112 | except NoOptionError: 113 | server_opts['ldap_username_prefix'] = '' 114 | try: 115 | server_opts['ldap_username_suffix'] = config.get('ldap', 'username_suffix') 116 | except NoOptionError: 117 | server_opts['ldap_username_suffix'] = '' 118 | try: 119 | server_opts['ldap_filter_memberof_key'] = config.get('ldap', 'filter_memberof_key') 120 | except NoOptionError: 121 | server_opts['ldap_filter_memberof_key'] = 'memberOf' 122 | try: 123 | ldap_mapping_path = config.get('ldap', 'ldap_mapping_path') 124 | if isfile(ldap_mapping_path): 125 | with open(ldap_mapping_path, 'r') as ldap_mapping_file: 126 | server_opts['ldap_mapping'] = json.loads(ldap_mapping_file.read()) 127 | except (NoOptionError, json.decoder.JSONDecodeError): 128 | pass 129 | 130 | if config.has_section('ssl'): 131 | try: 132 | server_opts['ssl'] = True 133 | server_opts['ssl_private_key'] = config.get('ssl', 'private_key') 134 | server_opts['ssl_public_key'] = config.get('ssl', 'public_key') 135 | except NoOptionError: 136 | if args.verbose: 137 | print('Option reading error (ssl).') 138 | sys.exit(1) 139 | 140 | # Cluster mode is used for revocation 141 | try: 142 | server_opts['cluster'] = config.get('main', 'cluster').split(',') 143 | except NoOptionError: 144 | # Standalone mode 145 | proto = 'http' 146 | if server_opts['ssl']: 147 | proto = 'https' 148 | server_opts['cluster'] = ['%s://localhost:%s' % (proto, server_opts['port'])] 149 | 150 | try: 151 | server_opts['clustersecret'] = config.get('main', 'clustersecret') 152 | except NoOptionError: 153 | # Standalone mode 154 | server_opts['clustersecret'] = random_string(32) 155 | 156 | try: 157 | server_opts['debug'] = bool(config.get('main', 'debug') != 'False') 158 | except NoOptionError: 159 | server_opts['debug'] = False 160 | 161 | tooling = Tools(server_opts, constants.STATES, version) 162 | return server_opts, args, tooling 163 | 164 | def get_ldap_conn(host, username, password, protocol, reuse=None): 165 | """ 166 | Returns an LDAP connection 167 | """ 168 | if reuse: 169 | ldap_conn = reuse 170 | else: 171 | is_starttls = protocol == 'starttls' 172 | if is_starttls: 173 | protocol = 'ldap' 174 | ldap_conn = initialize(protocol + "://" + host) 175 | if is_starttls: 176 | try: 177 | ldap_conn.start_tls_s() 178 | except Exception as err_msg: 179 | return False, 'Error: (ldap starttls) {}'.format(err_msg) 180 | try: 181 | ldap_conn.bind_s(username, password) 182 | except Exception as err_msg: 183 | return False, 'Error: {}'.format(err_msg) 184 | return ldap_conn, None 185 | 186 | def get_memberof(realname, server_options, reuse=None): 187 | """ 188 | Returns the list of memberOf groups 189 | """ 190 | if not server_options['ldap']: 191 | return list(), None 192 | if reuse: 193 | ldap_conn = reuse 194 | else: 195 | ldap_conn, err_msg = get_ldap_conn( 196 | server_options['ldap_host'], 197 | server_options['ldap_username'], 198 | server_options['ldap_password'], 199 | server_options['ldap_protocol']) 200 | if err_msg: 201 | return list(), 'Error: wrong cassh ldap credentials' 202 | try: 203 | output = ldap_conn.search_s( 204 | server_options['ldap_bind_dn'], 205 | SCOPE_SUBTREE, 206 | filterstr='(&({}={}))'.format( 207 | server_options['ldap_filter_realname_key'], 208 | realname)) 209 | except NO_SUCH_OBJECT: 210 | return list(), 'Error: admin LDAP filter is incorrect (no such object).' 211 | if not isinstance(output, list) or not output: 212 | return list(), None 213 | if len(output) != 1: 214 | return list(), 'Error: admin LDAP filter is incorrect (multiple user).' 215 | ldap_infos = output[0] 216 | if not isinstance(output, list) or not output: 217 | return list(), None 218 | for i in ldap_infos: 219 | if isinstance(i, dict) and server_options['ldap_filter_memberof_key'] in i: 220 | if isinstance(i[server_options['ldap_filter_memberof_key']], list): 221 | return i[server_options['ldap_filter_memberof_key']], None 222 | return list(), 'Error: admin LDAP output is incorrect.' 223 | return list(), 'Error: admin LDAP filter is incorrect.' 224 | 225 | def ldap_authentification(server_options, admin=False): 226 | """ 227 | Return True if user is well authentified 228 | realname=xxxxx@domain.fr 229 | password=xxxxx 230 | It returns also a list of memberof 231 | """ 232 | if not server_options['ldap']: 233 | return True, 'OK' 234 | credentials, message = data2map() 235 | if message: 236 | return False, response_render(message, http_code='400 Bad Request') 237 | if 'realname' in credentials: 238 | realname = unquote_plus(credentials['realname']) 239 | else: 240 | return False, 'Error: No realname option given.' 241 | if 'password' in credentials: 242 | password = unquote_plus(credentials['password']) 243 | else: 244 | return False, 'Error: No password option given.' 245 | if password == '': 246 | return False, 'Error: password is empty.' 247 | 248 | # user login to validate password 249 | ldap_conn, err_msg = get_ldap_conn( 250 | server_options['ldap_host'], 251 | '{}{}{}'.format( 252 | server_options['ldap_username_prefix'], 253 | realname, 254 | server_options['ldap_username_suffix']), 255 | password, 256 | server_options['ldap_protocol']) 257 | if err_msg: 258 | return False, err_msg 259 | 260 | # cassh service login 261 | ldap_conn, err_msg = get_ldap_conn( 262 | server_options['ldap_host'], 263 | server_options['ldap_username'], 264 | server_options['ldap_password'], 265 | server_options['ldap_protocol'], 266 | reuse=ldap_conn) 267 | if err_msg: 268 | return False, 'Error: wrong cassh ldap credentials' 269 | 270 | list_membership, err_msg = get_memberof( 271 | realname, 272 | server_options, 273 | reuse=ldap_conn) 274 | if err_msg: 275 | return False, err_msg 276 | 277 | if admin: 278 | if server_options['ldap_admin_cn'].encode() not in list_membership: 279 | return False, 'Error: Not authorized.' 280 | return True, 'OK' 281 | 282 | def validate_payload(key, value): 283 | """ 284 | Return an error message if invalid input 285 | """ 286 | new_value = unquote_plus(value) 287 | count = 10 288 | while value != new_value and count > 0 and \ 289 | key in ['username', 'principals', 'add', 'remove', 'update', 'filter', 'realname']: 290 | value = new_value 291 | new_value = unquote_plus(value) 292 | count -= 1 293 | 294 | err_msg = None 295 | 296 | if key == 'username' and constants.PATTERN_USERNAME.match(value) is None: 297 | err_msg = "Error: invalid username." 298 | elif key == 'realname' and constants.PATTERN_REALNAME.match(value) is None: 299 | err_msg = "Error: invalid realname." 300 | elif key == 'expiry' and constants.PATTERN_EXPIRY.match(value) is None: 301 | err_msg = "Error: invalid expiry." 302 | elif key in ['principals', 'add', 'remove', 'update']: 303 | for principal in value.split(','): 304 | if constants.PATTERN_PRINCIPALS.match(principal) is None: 305 | err_msg = "Error: invalid principals." 306 | elif key == 'filter': 307 | if value != '': 308 | for principal in value.split(','): 309 | if constants.PATTERN_PRINCIPALS.match(principal) is None: 310 | err_msg = "Error: invalid filter." 311 | return err_msg 312 | 313 | def data2map(): 314 | """ 315 | Returns a map from data POST and error 316 | """ 317 | data_map = {} 318 | data_str = data().decode('utf-8') 319 | if data_str == '': 320 | return data_map, None 321 | for key in data_str.split('&'): 322 | sub_key = key.split('=')[0] 323 | value = '='.join(key.split('=')[1:]) 324 | message = validate_payload(sub_key, value) 325 | if message: 326 | return None, message 327 | data_map[sub_key] = value 328 | return data_map, None 329 | 330 | def clean_principals_output(sql_result, username, shell=False): 331 | """ 332 | Transform sql principals into readable one 333 | """ 334 | if not sql_result: 335 | if shell: 336 | return username 337 | return [username] 338 | if shell: 339 | return sql_result 340 | return sql_result.split(',') 341 | 342 | def truncate_principals(custom_principals, list_membership, server_options): 343 | """ 344 | Returns custom_principals without LDAP principals 345 | """ 346 | if not custom_principals: 347 | return '' 348 | principals = custom_principals.split(',') 349 | if not server_options['ldap_mapping']: 350 | return ','.join(principals) 351 | for user_group_cn in list_membership: 352 | user_group_cn_decoded = user_group_cn.decode(errors='ignore') 353 | if user_group_cn_decoded not in server_options['ldap_mapping']: 354 | continue 355 | ldap_mapping_principals = server_options['ldap_mapping'][user_group_cn_decoded] 356 | for principal in ldap_mapping_principals: 357 | err_msg = validate_payload('principals', principal) 358 | if err_msg: 359 | print('Error: Invalid LDAP mapping configuration: err={}, principals={}'.format( 360 | err_msg, principal)) 361 | continue 362 | if principal in principals: 363 | principals.remove(principal) 364 | # Remove duplicates 365 | principals = list(dict.fromkeys(principals)) 366 | return ','.join(principals) 367 | 368 | def merge_principals(custom_principals, list_membership, server_options): 369 | """ 370 | Returns a custom_principals + LDAP principals 371 | """ 372 | if not custom_principals: 373 | return '' 374 | principals = custom_principals.split(',') 375 | if not server_options['ldap_mapping']: 376 | return ','.join(principals) 377 | for user_group_cn in list_membership: 378 | user_group_cn_decoded = user_group_cn.decode(errors='ignore') 379 | if user_group_cn_decoded not in server_options['ldap_mapping']: 380 | continue 381 | ldap_mapping_principals = server_options['ldap_mapping'][user_group_cn_decoded] 382 | for principal in ldap_mapping_principals: 383 | err_msg = validate_payload('principals', principal) 384 | if err_msg: 385 | print('Error: Invalid LDAP mapping configuration: err={}, principals={}'.format( 386 | err_msg, principal)) 387 | continue 388 | principals.append(principal) 389 | # Remove duplicates 390 | principals = list(dict.fromkeys(principals)) 391 | return ','.join(principals) 392 | 393 | def get_pubkey(username, pg_conn, key_n=0): 394 | """ 395 | Returns the public key stored in the USERS database 396 | For now, there is only one key per user, but it could change in the future 397 | """ 398 | cur = pg_conn.cursor() 399 | cur.execute( 400 | """ 401 | SELECT SSH_KEY FROM USERS WHERE NAME=(%s) 402 | """, (username,)) 403 | pubkeys = cur.fetchall() 404 | cur.close() 405 | 406 | if len(pubkeys) <= key_n: 407 | return None 408 | if not pubkeys[key_n]: 409 | return None 410 | pubkey = pubkeys[key_n][0] 411 | 412 | return pubkey 413 | 414 | def pretty_ssh_key_hash(pubkey_fingerprint): 415 | """ 416 | Returns a pretty json from raw pubkey 417 | KEY_BITS KEY_HASH [JERK] (AUTH_TYPE) 418 | """ 419 | try: 420 | key_bits = int(pubkey_fingerprint.split(' ')[0]) 421 | except ValueError: 422 | key_bits = 0 423 | except IndexError: 424 | key_bits = 0 425 | 426 | try: 427 | key_hash = pubkey_fingerprint.split(' ')[1] 428 | except IndexError: 429 | key_hash = pubkey_fingerprint 430 | 431 | try: 432 | auth_type = pubkey_fingerprint.split('(')[-1].split(')')[0] 433 | except IndexError: 434 | auth_type = 'Unknown' 435 | 436 | rate = 'UNKNOWN' 437 | if auth_type == 'DSA': 438 | rate = 'VERY LOW' 439 | elif (auth_type == 'RSA' and key_bits >= 4096) or (auth_type == 'ECDSA' and key_bits >= 256): 440 | rate = 'HIGH' 441 | elif auth_type == 'RSA' and key_bits >= 2048: 442 | rate = 'MEDIUM' 443 | elif auth_type == 'RSA' and key_bits < 2048: 444 | rate = 'LOW' 445 | elif auth_type == 'ED25519' and key_bits >= 256: 446 | rate = 'VERY HIGH' 447 | 448 | return {'bits': key_bits, 'hash': key_hash, 'auth_type': auth_type, 'rate': rate} 449 | 450 | def random_string(string_length=10): 451 | """Generate a random string of fixed length """ 452 | letters = ascii_lowercase 453 | return ''.join(choice(letters) for i in range(string_length)) 454 | 455 | def response_render(message, http_code='200 OK', content_type='text/plain'): 456 | """ 457 | This function returns a well-formed HTTP response 458 | """ 459 | header('Content-Type', content_type) 460 | ctx.status = http_code 461 | return message 462 | 463 | def str2date(string): 464 | """ 465 | change xd => seconds 466 | change xh => seconds 467 | """ 468 | delta = 0 469 | if 'd' in string: 470 | delta = timedelta(days=int(string.split('d')[0])).total_seconds() 471 | elif 'h' in string: 472 | delta = timedelta(hours=int(string.split('h')[0])).total_seconds() 473 | return delta 474 | 475 | def timestamp(): 476 | """ 477 | Returns the epoch time of now 478 | """ 479 | return time() 480 | 481 | def unquote_custom(string): 482 | """ 483 | Returns True is the string is quoted 484 | """ 485 | if ' ' not in string: 486 | string = unquote_plus(string) 487 | if '+' in string and '%20' in string: 488 | # Old custom quote 489 | string = string.replace('%20', ' ') 490 | return string 491 | 492 | 493 | class Tools(): 494 | """ 495 | Class tools 496 | """ 497 | def __init__(self, server_opts, states, version): 498 | self.server_opts = server_opts 499 | self.states = states 500 | self.version = version 501 | self.req_headers = { 502 | 'User-Agent': 'CASSH-SERVER v%s' % version, 503 | 'SERVER_VERSION': version, 504 | } 505 | self.req_timeout = 2 506 | 507 | def cluster_alived(self): 508 | """ 509 | This function returns a subset of pingeable node 510 | """ 511 | alive_nodes = list() 512 | dead_nodes = list() 513 | if not self.server_opts['cluster'] or \ 514 | self.server_opts['cluster'] == ['']: 515 | return alive_nodes, dead_nodes 516 | for node in self.server_opts['cluster']: 517 | req = self.get("%s/ping" % node) 518 | if req is not None and req.text == 'pong': 519 | alive_nodes.append(node) 520 | else: 521 | dead_nodes.append(node) 522 | return alive_nodes, dead_nodes 523 | 524 | def get(self, url): 525 | """ 526 | Rebuilt GET function for CASSH purpose 527 | """ 528 | session = Session() 529 | try: 530 | req = session.get(url, 531 | headers=self.req_headers, 532 | timeout=self.req_timeout, 533 | stream=True) 534 | except req_ConnectionError: 535 | print('Connection error : %s' % url) 536 | req = None 537 | return req 538 | 539 | def get_last_krl(self): 540 | """ 541 | Generates or returns a KRL file 542 | """ 543 | pg_conn, message = self.pg_connection() 544 | if pg_conn is None: 545 | return response_render(message, http_code='503 Service Unavailable') 546 | cur = pg_conn.cursor() 547 | cur.execute( 548 | """ 549 | SELECT MAX(REVOCATION_DATE) FROM REVOCATION 550 | """) 551 | last_timestamp = cur.fetchone() 552 | if not last_timestamp[0]: 553 | return response_render( 554 | open(self.server_opts['krl'], 'rb'), 555 | content_type='application/octet-stream') 556 | 557 | last_krl = '%s.%s' % (self.server_opts['krl'], last_timestamp[0]) 558 | 559 | # Check if the KRL is up-to-date 560 | if not isfile(last_krl): 561 | ca_ssh = Authority(self.server_opts['ca'], last_krl) 562 | ca_ssh.generate_empty_krl() 563 | 564 | cur.execute('SELECT SSH_KEY FROM REVOCATION') 565 | pubkeys = cur.fetchall() 566 | 567 | if not pubkeys or not pubkeys[0]: 568 | cur.close() 569 | pg_conn.close() 570 | return response_render( 571 | open(self.server_opts['krl'], 'rb'), 572 | content_type='application/octet-stream') 573 | 574 | for pubkey in pubkeys: 575 | with NamedTemporaryFile(delete=False) as tmp_pubkey: 576 | tmp_pubkey.write(bytes(pubkey[0], 'utf-8')) 577 | ca_ssh.update_krl(tmp_pubkey.name) 578 | remove(tmp_pubkey.name) 579 | 580 | copyfile(last_krl, self.server_opts['krl']) 581 | 582 | # Clean old files 583 | old_files = glob('%s*' % self.server_opts['krl']) 584 | old_files.remove(self.server_opts['krl']) 585 | old_files.remove(last_krl) 586 | for old_file in old_files: 587 | remove(old_file) 588 | 589 | cur.close() 590 | pg_conn.close() 591 | 592 | return response_render( 593 | open(self.server_opts['krl'], 'rb'), 594 | content_type='application/octet-stream') 595 | 596 | def list_keys(self, username=None, realname=None): 597 | """ 598 | Return all keys. 599 | """ 600 | pg_conn, message = self.pg_connection() 601 | if pg_conn is None: 602 | return response_render(message, http_code='503 Service Unavailable') 603 | cur = pg_conn.cursor() 604 | is_list = False 605 | 606 | if realname is not None: 607 | cur.execute('SELECT * FROM USERS WHERE REALNAME=(%s)', (realname,)) 608 | result = cur.fetchone() 609 | elif username is not None: 610 | cur.execute('SELECT * FROM USERS WHERE NAME=(%s)', (username,)) 611 | result = cur.fetchone() 612 | else: 613 | cur.execute('SELECT * FROM USERS') 614 | result = cur.fetchall() 615 | is_list = True 616 | cur.close() 617 | pg_conn.close() 618 | return self.sql_to_json(result, is_list=is_list) 619 | 620 | def pg_connection(self): 621 | """ 622 | Return a connection to the db. 623 | """ 624 | dbname = self.server_opts['db_name'] 625 | user = self.server_opts['db_user'] 626 | host = self.server_opts['db_host'] 627 | password = self.server_opts['db_password'] 628 | message = '' 629 | try: 630 | pg_conn = connect("dbname='%s' user='%s' host='%s' password='%s'"\ 631 | % (dbname, user, host, password)) 632 | except OperationalError: 633 | return None, 'Error : Server cannot connect to database' 634 | try: 635 | pg_conn.cursor().execute("""SELECT * FROM USERS""") 636 | except ProgrammingError: 637 | return None, 'Error : Server cannot connect to table in database' 638 | return pg_conn, message 639 | 640 | def post(self, url, payload): 641 | """ 642 | Rebuilt POST function for CASSH purpose 643 | """ 644 | session = Session() 645 | try: 646 | req = session.post(url, 647 | data=payload, 648 | headers=self.req_headers, 649 | timeout=self.req_timeout) 650 | except req_ConnectionError: 651 | print('Connection error : %s' % url) 652 | req = None 653 | return req 654 | 655 | def sign_key(self, tmp_pubkey_name, username, expiry, principals, db_cursor=None): 656 | """ 657 | Sign a key and return cert_contents 658 | """ 659 | # Load SSH CA 660 | ca_ssh = Authority(self.server_opts['ca'], self.server_opts['krl']) 661 | 662 | # Sign the key 663 | try: 664 | cert_contents = ca_ssh.sign_public_user_key(\ 665 | tmp_pubkey_name, username, '+'+expiry, principals) 666 | if db_cursor is not None: 667 | db_cursor.execute('UPDATE USERS SET STATE=0, EXPIRATION=(%s) WHERE NAME=(%s)', \ 668 | (time() + str2date(expiry), username)) 669 | except Exception: 670 | cert_contents = 'Error : signing key' 671 | return cert_contents 672 | 673 | def sql_to_json(self, result, is_list=False): 674 | """ 675 | This function prettify a sql result into json 676 | """ 677 | if result is None: 678 | return None 679 | ldap_conn = None 680 | if self.server_opts['ldap']: 681 | ldap_conn, _ = get_ldap_conn( 682 | self.server_opts['ldap_host'], 683 | self.server_opts['ldap_username'], 684 | self.server_opts['ldap_password'], 685 | self.server_opts['ldap_protocol']) 686 | if is_list: 687 | d_result = {} 688 | for res in result: 689 | d_sub_result = {} 690 | d_sub_result['username'] = res[0] 691 | d_sub_result['realname'] = res[1] 692 | d_sub_result['status'] = self.states[res[2]] 693 | d_sub_result['expiration'] = datetime.fromtimestamp(res[3]).strftime( 694 | '%Y-%m-%d %H:%M:%S') 695 | d_sub_result['ssh_key_hash'] = pretty_ssh_key_hash(res[4]) 696 | d_sub_result['expiry'] = res[6] 697 | list_membership, _ = get_memberof( 698 | res[1], 699 | self.server_opts, 700 | reuse=ldap_conn) 701 | full_principals = merge_principals(res[7], list_membership, self.server_opts) 702 | d_sub_result['principals'] = clean_principals_output(full_principals, res[0]) 703 | d_result[res[0]] = d_sub_result 704 | return json.dumps(d_result, indent=4, sort_keys=True) 705 | d_result = {} 706 | d_result['username'] = result[0] 707 | d_result['realname'] = result[1] 708 | d_result['status'] = self.states[result[2]] 709 | d_result['expiration'] = datetime.fromtimestamp(result[3]).strftime( 710 | '%Y-%m-%d %H:%M:%S') 711 | d_result['ssh_key_hash'] = pretty_ssh_key_hash(result[4]) 712 | d_result['expiry'] = result[6] 713 | list_membership, _ = get_memberof( 714 | result[1], 715 | self.server_opts, 716 | reuse=ldap_conn) 717 | full_principals = merge_principals(result[7], list_membership, self.server_opts) 718 | d_result['principals'] = clean_principals_output(full_principals, result[0]) 719 | return json.dumps(d_result, indent=4, sort_keys=True) 720 | -------------------------------------------------------------------------------- /src/server/server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Sign a user's SSH public key. 4 | 5 | Copyright 2017-2025 Nicolas BEGUIER 6 | Licensed under the Apache License, Version 2.0 7 | Written by Nicolas BEGUIER (nicolas_beguier@hotmail.com) 8 | 9 | """ 10 | # pylint: disable=invalid-name,too-many-return-statements,no-self-use,too-many-locals 11 | # pylint: disable=too-many-branches,too-few-public-methods,too-many-statements 12 | # pylint: disable=too-many-nested-blocks,arguments-differ,W1113 13 | 14 | from json import dumps 15 | from os import remove 16 | from tempfile import NamedTemporaryFile 17 | from urllib.parse import unquote_plus 18 | 19 | # Third party library imports 20 | from cheroot.server import HTTPServer 21 | from cheroot.ssl.builtin import BuiltinSSLAdapter 22 | import web 23 | 24 | # Own library 25 | from ssh_utils import get_fingerprint 26 | import lib.constants as constants 27 | import lib.tools as tools 28 | 29 | # DEBUG 30 | # from pdb import set_trace as st 31 | 32 | VERSION = '2.3.1' 33 | 34 | SERVER_OPTS, ARGS, TOOLS = tools.loadconfig(version=VERSION) 35 | 36 | class Admin(): 37 | """ 38 | Class admin to action or revoke keys. 39 | """ 40 | def POST(self, username): 41 | """ 42 | Revoke or Active keys. 43 | /admin/ 44 | revoke=true/false => Revoke user 45 | status=true/false => Display status 46 | """ 47 | # LDAP authentication 48 | is_admin_auth, message = tools.ldap_authentification(SERVER_OPTS, admin=True) 49 | if not is_admin_auth: 50 | return tools.response_render(message, http_code='401 Unauthorized') 51 | 52 | payload, message = tools.data2map() 53 | if message: 54 | return tools.response_render(message, http_code='400 Bad Request') 55 | 56 | if 'revoke' in payload: 57 | do_revoke = payload['revoke'].lower() == 'true' 58 | else: 59 | do_revoke = False 60 | if 'status' in payload: 61 | do_status = payload['status'].lower() == 'true' 62 | else: 63 | do_status = False 64 | 65 | pg_conn, message = TOOLS.pg_connection() 66 | if pg_conn is None: 67 | return tools.response_render(message, http_code='503 Service Unavailable') 68 | cur = pg_conn.cursor() 69 | 70 | if username == 'all' and do_status: 71 | return tools.response_render( 72 | TOOLS.list_keys(), 73 | content_type='application/json') 74 | 75 | # Search if key already exists 76 | cur.execute( 77 | """ 78 | SELECT STATE FROM USERS WHERE NAME=(%s) 79 | """, (username,)) 80 | user_state = cur.fetchone() 81 | # If user dont exist 82 | if user_state is None: 83 | cur.close() 84 | pg_conn.close() 85 | message = 'User does not exists.' 86 | elif do_revoke: 87 | cur.execute( 88 | """ 89 | UPDATE USERS SET STATE=1 WHERE NAME=(%s) 90 | """, (username,)) 91 | pg_conn.commit() 92 | pubkey = tools.get_pubkey(username, pg_conn) 93 | cur.execute( 94 | """ 95 | SELECT 1 FROM REVOCATION WHERE SSH_KEY=(%s) 96 | """, (pubkey,)) 97 | if cur.fetchone() is None: 98 | cur.execute( 99 | """ 100 | INSERT INTO REVOCATION VALUES ((%s), (%s), (%s)) 101 | """, (pubkey, tools.timestamp(), username)) 102 | pg_conn.commit() 103 | message = 'Revoke user={}.'.format(username) 104 | else: 105 | message = 'user {} already revoked.'.format(username) 106 | cur.close() 107 | pg_conn.close() 108 | # Display status 109 | elif do_status: 110 | return tools.response_render( 111 | TOOLS.list_keys(username=username), 112 | content_type='application/json') 113 | # If user is in PENDING state 114 | elif user_state[0] == constants.STATES['PENDING']: 115 | cur.execute( 116 | """ 117 | UPDATE USERS SET STATE=0 WHERE NAME=(%s) 118 | """, (username,)) 119 | pg_conn.commit() 120 | cur.close() 121 | pg_conn.close() 122 | message = 'Active user=%s. SSH Key active but need to be signed.' % username 123 | # If user is in REVOKED state 124 | elif user_state[0] == constants.STATES['REVOKED']: 125 | cur.execute('UPDATE USERS SET STATE=0 WHERE NAME=(%s)', (username,)) 126 | pg_conn.commit() 127 | cur.close() 128 | pg_conn.close() 129 | message = 'Active user=%s. SSH Key active but need to be signed.' % username 130 | else: 131 | cur.close() 132 | pg_conn.close() 133 | message = 'user=%s already active. Nothing done.' % username 134 | return tools.response_render(message) 135 | 136 | def PATCH(self, username): 137 | """ 138 | Set the first founded value. 139 | /admin/ 140 | key=value => Set the key value. Keys are in status output. 141 | """ 142 | # LDAP authentication 143 | is_admin_auth, message = tools.ldap_authentification(SERVER_OPTS, admin=True) 144 | if not is_admin_auth: 145 | return tools.response_render(message, http_code='401 Unauthorized') 146 | 147 | pg_conn, message = TOOLS.pg_connection() 148 | if pg_conn is None: 149 | return tools.response_render(message, http_code='503 Service Unavailable') 150 | cur = pg_conn.cursor() 151 | 152 | payload, message = tools.data2map() 153 | if message: 154 | return tools.response_render(message, http_code='400 Bad Request') 155 | 156 | for key, value in payload.items(): 157 | if key == 'expiry': 158 | cur.execute( 159 | """ 160 | UPDATE USERS SET EXPIRY=(%s) WHERE NAME=(%s) 161 | """, (value, username)) 162 | pg_conn.commit() 163 | cur.close() 164 | pg_conn.close() 165 | return tools.response_render( 166 | 'OK: %s=%s for %s' % (key, value, username)) 167 | return tools.response_render('WARNING: No key found...') 168 | 169 | def DELETE(self, username): 170 | """ 171 | Delete keys (but DOESN'T REVOKE) 172 | /admin/ 173 | """ 174 | # LDAP authentication 175 | is_admin_auth, message = tools.ldap_authentification(SERVER_OPTS, admin=True) 176 | if not is_admin_auth: 177 | return tools.response_render(message, http_code='401 Unauthorized') 178 | 179 | pg_conn, message = TOOLS.pg_connection() 180 | if pg_conn is None: 181 | return tools.response_render(message, http_code='503 Service Unavailable') 182 | cur = pg_conn.cursor() 183 | 184 | # Search if key already exists 185 | cur.execute( 186 | """ 187 | DELETE FROM USERS WHERE NAME=(%s) 188 | """, (username,)) 189 | pg_conn.commit() 190 | cur.close() 191 | pg_conn.close() 192 | return tools.response_render('OK') 193 | 194 | 195 | class Ca(): 196 | """ 197 | Class CA. 198 | """ 199 | def GET(self): 200 | """ 201 | Return ca. 202 | """ 203 | return tools.response_render( 204 | open(SERVER_OPTS['ca'] + '.pub', 'rb'), 205 | content_type='application/octet-stream') 206 | 207 | class ClientStatus(): 208 | """ 209 | ClientStatus main class. 210 | """ 211 | def POST(self): 212 | """ 213 | Get client key status. 214 | /client/status 215 | """ 216 | # LDAP authentication 217 | is_auth, message = tools.ldap_authentification(SERVER_OPTS) 218 | if not is_auth: 219 | return tools.response_render(message, http_code='401 Unauthorized') 220 | 221 | payload, message = tools.data2map() 222 | if message: 223 | return tools.response_render(message, http_code='400 Bad Request') 224 | 225 | if 'realname' in payload: 226 | realname = unquote_plus(payload['realname']) 227 | else: 228 | return tools.response_render( 229 | 'Error: No realname option given.', 230 | http_code='400 Bad Request') 231 | 232 | return tools.response_render( 233 | TOOLS.list_keys(realname=realname), 234 | content_type='application/json') 235 | 236 | class Client(): 237 | """ 238 | Client main class. 239 | """ 240 | def POST(self): 241 | """ 242 | Ask to sign pub key. 243 | /client 244 | username=xxxxxx => Unique username. Used by default to connect on server. 245 | realname=xxxxx@domain.fr => This LDAP/AD user. 246 | 247 | # Optionnal 248 | admin_force=true|false 249 | """ 250 | # LDAP authentication 251 | is_auth, message = tools.ldap_authentification(SERVER_OPTS) 252 | if not is_auth: 253 | return tools.response_render(message, http_code='401 Unauthorized') 254 | 255 | # Check if user is an admin and want to force signature when db fail 256 | force_sign = False 257 | 258 | # LDAP ADMIN authentication 259 | is_admin_auth, message = tools.ldap_authentification( 260 | SERVER_OPTS, admin=True) 261 | 262 | payload, message = tools.data2map() 263 | if message: 264 | return tools.response_render(message, http_code='400 Bad Request') 265 | 266 | if is_admin_auth and SERVER_OPTS['admin_db_failover'] \ 267 | and 'admin_force' in payload and payload['admin_force'].lower() == 'true': 268 | force_sign = True 269 | 270 | # Get username 271 | if 'username' in payload: 272 | username = payload['username'] 273 | else: 274 | return tools.response_render( 275 | 'Error: No username option given.', 276 | http_code='400 Bad Request') 277 | if username == 'all': 278 | return tools.response_render( 279 | "Error: username not valid.", 280 | http_code='400 Bad Request') 281 | 282 | # Get realname 283 | if 'realname' in payload: 284 | realname = unquote_plus(payload['realname']) 285 | else: 286 | return tools.response_render( 287 | 'Error: No realname option given.', 288 | http_code='400 Bad Request') 289 | 290 | # Get public key 291 | if 'pubkey' in payload: 292 | pubkey = tools.unquote_custom(payload['pubkey']) 293 | else: 294 | return tools.response_render( 295 | 'Error: No pubkey given.', 296 | http_code='400 Bad Request') 297 | with NamedTemporaryFile(delete=False) as tmp_pubkey: 298 | tmp_pubkey.write(bytes(pubkey, 'utf-8')) 299 | 300 | pubkey_fingerprint = get_fingerprint(tmp_pubkey.name) 301 | if pubkey_fingerprint == 'Unknown': 302 | remove(tmp_pubkey.name) 303 | return tools.response_render( 304 | 'Error : Public key unprocessable', 305 | http_code='422 Unprocessable Entity') 306 | 307 | pg_conn, message = TOOLS.pg_connection() 308 | # Admin force signature case 309 | if pg_conn is None and force_sign: 310 | cert_contents = TOOLS.sign_key(tmp_pubkey.name, username, '+12h', username) 311 | remove(tmp_pubkey.name) 312 | return tools.response_render(cert_contents, content_type='application/octet-stream') 313 | # Check if db is up 314 | if pg_conn is None: 315 | remove(tmp_pubkey.name) 316 | return tools.response_render(message, http_code='503 Service Unavailable') 317 | cur = pg_conn.cursor() 318 | 319 | # Search if user already exists 320 | cur.execute( 321 | """ 322 | SELECT NAME,REALNAME,STATE,EXPIRY,PRINCIPALS,SSH_KEY FROM USERS 323 | WHERE NAME=lower(%s) 324 | """, (username,)) 325 | user = cur.fetchone() 326 | if user is None: 327 | cur.close() 328 | pg_conn.close() 329 | remove(tmp_pubkey.name) 330 | return tools.response_render( 331 | 'Error : User absent, please create an account.', 332 | http_code='400 Bad Request') 333 | 334 | # Get database key fingerprint 335 | with NamedTemporaryFile(delete=False) as db_pubkey: 336 | db_pubkey.write(bytes(user[5], 'utf-8')) 337 | db_pubkey_fingerprint = get_fingerprint(db_pubkey.name) 338 | remove(db_pubkey.name) 339 | 340 | if db_pubkey_fingerprint == 'Unknown': 341 | remove(tmp_pubkey.name) 342 | return tools.response_render( 343 | 'Error : Public key from database unprocessable', 344 | http_code='422 Unprocessable Entity') 345 | 346 | if username != user[0] or \ 347 | realname != user[1] or \ 348 | db_pubkey_fingerprint != pubkey_fingerprint: 349 | cur.close() 350 | pg_conn.close() 351 | remove(tmp_pubkey.name) 352 | return tools.response_render( 353 | 'Error : (username, realname, pubkey) triple mismatch.', 354 | http_code='401 Unauthorized') 355 | 356 | status = user[2] 357 | expiry = user[3] 358 | custom_principals = tools.clean_principals_output(user[4], username, shell=True) 359 | list_membership, _ = tools.get_memberof( 360 | realname, 361 | SERVER_OPTS) 362 | full_principals = tools.merge_principals(custom_principals, list_membership, SERVER_OPTS) 363 | 364 | if status > 0: 365 | cur.close() 366 | pg_conn.close() 367 | remove(tmp_pubkey.name) 368 | return tools.response_render("Status: %s" % constants.STATES[user[2]]) 369 | 370 | cert_contents = TOOLS.sign_key( 371 | tmp_pubkey.name, username, expiry, full_principals, db_cursor=cur) 372 | 373 | remove(tmp_pubkey.name) 374 | pg_conn.commit() 375 | cur.close() 376 | pg_conn.close() 377 | return tools.response_render( 378 | cert_contents, 379 | content_type='application/octet-stream') 380 | 381 | def PUT(self): 382 | """ 383 | This function permit to add or update a ssh public key. 384 | /client 385 | username=xxxxxx => Unique username. Used by default to connect on server. 386 | realname=xxxxx@domain.fr => This LDAP/AD user. 387 | """ 388 | # LDAP authentication 389 | is_auth, message = tools.ldap_authentification(SERVER_OPTS) 390 | if not is_auth: 391 | return tools.response_render(message, http_code='401 Unauthorized') 392 | 393 | payload, message = tools.data2map() 394 | if message: 395 | return tools.response_render(message, http_code='400 Bad Request') 396 | 397 | if 'username' in payload: 398 | username = payload['username'] 399 | else: 400 | return tools.response_render( 401 | 'Error: No username option given.', 402 | http_code='400 Bad Request') 403 | 404 | if username == 'all': 405 | return tools.response_render( 406 | "Error: username not valid.", 407 | http_code='400 Bad Request') 408 | 409 | 410 | if 'realname' in payload: 411 | realname = unquote_plus(payload['realname']) 412 | else: 413 | return tools.response_render( 414 | 'Error: No realname option given.', 415 | http_code='400 Bad Request') 416 | 417 | if constants.PATTERN_REALNAME.match(realname) is None: 418 | return tools.response_render( 419 | "Error: realname doesn't match pattern", 420 | http_code='400 Bad Request') 421 | 422 | # Get public key 423 | if 'pubkey' in payload: 424 | pubkey = tools.unquote_custom(payload['pubkey']) 425 | else: 426 | return tools.response_render( 427 | 'Error: No pubkey given.', 428 | http_code='400 Bad Request') 429 | with NamedTemporaryFile(delete=False) as tmp_pubkey: 430 | tmp_pubkey.write(bytes(pubkey, 'utf-8')) 431 | 432 | pubkey_fingerprint = get_fingerprint(tmp_pubkey.name) 433 | if pubkey_fingerprint == 'Unknown': 434 | remove(tmp_pubkey.name) 435 | return tools.response_render( 436 | 'Error : Public key unprocessable', 437 | http_code='422 Unprocessable Entity') 438 | 439 | pg_conn, message = TOOLS.pg_connection() 440 | if pg_conn is None: 441 | remove(tmp_pubkey.name) 442 | return tools.response_render(message, http_code='503 Service Unavailable') 443 | cur = pg_conn.cursor() 444 | 445 | # Search if key already exists 446 | cur.execute( 447 | """ 448 | SELECT 1 FROM USERS WHERE NAME=(%s) 449 | """, (username,)) 450 | user = cur.fetchone() 451 | # CREATE NEW USER 452 | if user is None: 453 | cur.execute( 454 | """ 455 | INSERT INTO USERS VALUES ((%s), (%s), (%s), (%s), (%s), (%s), (%s), (%s)) 456 | """, ( 457 | username, realname, constants.STATES['PENDING'], 458 | 0, pubkey_fingerprint, pubkey, '+12h', username)) 459 | pg_conn.commit() 460 | cur.close() 461 | pg_conn.close() 462 | remove(tmp_pubkey.name) 463 | return tools.response_render( 464 | 'Create user=%s. Pending request.' % username, 465 | http_code='201 Created') 466 | # Check if realname is the same 467 | cur.execute( 468 | """ 469 | SELECT 1 FROM USERS WHERE NAME=(%s) AND REALNAME=lower((%s)) 470 | """, (username, realname)) 471 | if cur.fetchone() is None: 472 | pg_conn.commit() 473 | cur.close() 474 | pg_conn.close() 475 | remove(tmp_pubkey.name) 476 | return tools.response_render( 477 | 'Error : (username, realname) couple mismatch.', 478 | http_code='401 Unauthorized') 479 | # Update entry into database 480 | cur.execute( 481 | """ 482 | UPDATE USERS 483 | SET SSH_KEY=(%s),SSH_KEY_HASH=(%s), STATE=(%s), EXPIRATION=0 484 | WHERE NAME=(%s) 485 | """, (pubkey, pubkey_fingerprint, constants.STATES['PENDING'], username)) 486 | pg_conn.commit() 487 | cur.close() 488 | pg_conn.close() 489 | remove(tmp_pubkey.name) 490 | return tools.response_render('Update user=%s. Pending request.' % username) 491 | 492 | 493 | class ClusterStatus(): 494 | """ 495 | ClusterStatus main class. 496 | """ 497 | def GET(self): 498 | """ 499 | /cluster/status 500 | """ 501 | message = dict() 502 | alive_nodes, dead_nodes = TOOLS.cluster_alived() 503 | for node in alive_nodes: 504 | message.update({node: {'status': 'OK'}}) 505 | for node in dead_nodes: 506 | message.update({node: {'status': 'KO'}}) 507 | return tools.response_render( 508 | dumps(message), 509 | content_type='application/json') 510 | 511 | 512 | class Health(): 513 | """ 514 | Class Health 515 | """ 516 | def GET(self): 517 | """ 518 | Return a health check 519 | """ 520 | health = {} 521 | health['name'] = 'cassh' 522 | health['version'] = VERSION 523 | return tools.response_render( 524 | dumps(health, indent=4, sort_keys=True), 525 | content_type='application/json') 526 | 527 | 528 | class Krl(): 529 | """ 530 | Class KRL. 531 | """ 532 | def GET(self): 533 | """ 534 | Return krl. 535 | """ 536 | return TOOLS.get_last_krl() 537 | 538 | 539 | class Ping(): 540 | """ 541 | Class Ping 542 | """ 543 | def GET(self): 544 | """ 545 | Return a pong 546 | """ 547 | return tools.response_render('pong') 548 | 549 | 550 | class Principals(): 551 | """ 552 | Class Principals 553 | """ 554 | def POST(self, username): 555 | """ 556 | Manage user principals 557 | """ 558 | # LDAP authentication 559 | is_admin_auth, message = tools.ldap_authentification(SERVER_OPTS, admin=True) 560 | if not is_admin_auth: 561 | return tools.response_render(message, http_code='401 Unauthorized') 562 | 563 | pg_conn, message = TOOLS.pg_connection() 564 | if pg_conn is None: 565 | return tools.response_render(message, http_code='503 Service Unavailable') 566 | cur = pg_conn.cursor() 567 | 568 | payload, message = tools.data2map() 569 | if message: 570 | return tools.response_render(message, http_code='400 Bad Request') 571 | 572 | if 'add' not in payload and \ 573 | 'remove' not in payload and \ 574 | 'update' not in payload and \ 575 | 'purge' not in payload: 576 | return tools.response_render( 577 | '[ERROR] Unknown action', 578 | http_code='400 Bad Request') 579 | 580 | # Search if username exists 581 | values = {'username': username} 582 | cur.execute( 583 | """ 584 | SELECT NAME,PRINCIPALS,REALNAME FROM USERS WHERE NAME=(%(username)s) 585 | """, values) 586 | user = cur.fetchone() 587 | # If user dont exist 588 | if user is None: 589 | cur.close() 590 | pg_conn.close() 591 | return tools.response_render( 592 | "ERROR: {} doesn't exist".format(username), 593 | http_code='400 Bad Request') 594 | values['principals'] = user[1] 595 | 596 | for key, value in payload.items(): 597 | value = unquote_plus(value) 598 | if key == 'add': 599 | for principal in value.split(','): 600 | if constants.PATTERN_PRINCIPALS.match(principal) is None: 601 | return tools.response_render( 602 | "Error: principal doesn't match pattern {}".format( 603 | constants.PATTERN_PRINCIPALS.pattern), 604 | http_code='400 Bad Request') 605 | if values['principals']: 606 | values['principals'] += ',' + value 607 | else: 608 | values['principals'] = value 609 | elif key == 'remove': 610 | principals = values['principals'].split(',') 611 | for principal in value.split(','): 612 | if constants.PATTERN_PRINCIPALS.match(principal) is None: 613 | return tools.response_render( 614 | "Error: principal doesn't match pattern {}".format( 615 | constants.PATTERN_PRINCIPALS.pattern), 616 | http_code='400 Bad Request') 617 | if principal in principals: 618 | principals.remove(principal) 619 | values['principals'] = ','.join(principals) 620 | elif key == 'update': 621 | for principal in value.split(','): 622 | if constants.PATTERN_PRINCIPALS.match(principal) is None: 623 | return tools.response_render( 624 | "Error: principal doesn't match pattern {}".format( 625 | constants.PATTERN_PRINCIPALS.pattern), 626 | http_code='400 Bad Request') 627 | values['principals'] = value 628 | elif key == 'purge': 629 | values['principals'] = username 630 | 631 | list_membership, _ = tools.get_memberof( 632 | user[2], 633 | SERVER_OPTS) 634 | values['principals'] = tools.truncate_principals( 635 | values['principals'], 636 | list_membership, 637 | SERVER_OPTS) 638 | 639 | cur.execute( 640 | """ 641 | UPDATE USERS SET PRINCIPALS=(%(principals)s) WHERE NAME=(%(username)s) 642 | """, values) 643 | pg_conn.commit() 644 | cur.close() 645 | pg_conn.close() 646 | 647 | # Add LDAP principals 648 | values['principals'] = tools.merge_principals( 649 | values['principals'], 650 | list_membership, 651 | SERVER_OPTS) 652 | 653 | return tools.response_render( 654 | "OK: {} principals are '{}'".format(username, values['principals'])) 655 | 656 | 657 | class PrincipalsSearch(): 658 | """ 659 | Class Principals Search 660 | """ 661 | def POST(self): 662 | """ 663 | Search user's principals by filter 664 | """ 665 | # LDAP authentication 666 | is_admin_auth, message = tools.ldap_authentification(SERVER_OPTS, admin=True) 667 | if not is_admin_auth: 668 | return tools.response_render(message, http_code='401 Unauthorized') 669 | 670 | pg_conn, message = TOOLS.pg_connection() 671 | if pg_conn is None: 672 | return tools.response_render(message, http_code='503 Service Unavailable') 673 | cur = pg_conn.cursor() 674 | 675 | payload, message = tools.data2map() 676 | if message: 677 | return tools.response_render(message, http_code='400 Bad Request') 678 | 679 | if 'filter' not in payload: 680 | return tools.response_render( 681 | '[ERROR] Unknown action', 682 | http_code='400 Bad Request') 683 | 684 | cur.execute( 685 | """ 686 | SELECT NAME,PRINCIPALS,REALNAME FROM USERS 687 | """) 688 | all_principals = cur.fetchall() 689 | pg_conn.commit() 690 | cur.close() 691 | pg_conn.close() 692 | 693 | if SERVER_OPTS['ldap']: 694 | ldap_conn, _ = tools.get_ldap_conn( 695 | SERVER_OPTS['ldap_host'], 696 | SERVER_OPTS['ldap_username'], 697 | SERVER_OPTS['ldap_password'], 698 | SERVER_OPTS['ldap_protocol']) 699 | 700 | result = dict() 701 | 702 | for key, value in payload.items(): 703 | value = unquote_plus(value) 704 | if key == 'filter' and value == '': 705 | for name, custom_principals, realname in all_principals: 706 | if not isinstance(custom_principals, str): 707 | continue 708 | list_membership, _ = tools.get_memberof( 709 | realname, 710 | SERVER_OPTS, 711 | reuse=ldap_conn) 712 | result[name] = tools.merge_principals( 713 | custom_principals, 714 | list_membership, 715 | SERVER_OPTS).split(',') 716 | elif key == 'filter': 717 | for principal in value.split(','): 718 | for name, custom_principals, realname in all_principals: 719 | if not isinstance(custom_principals, str): 720 | continue 721 | list_membership, _ = tools.get_memberof( 722 | realname, 723 | SERVER_OPTS, 724 | reuse=ldap_conn) 725 | principals = tools.merge_principals( 726 | custom_principals, 727 | list_membership, 728 | SERVER_OPTS).split(',') 729 | if principal in principals: 730 | if name not in result: 731 | result[name] = list() 732 | result[name].append(principal) 733 | 734 | return tools.response_render(dumps(result)) 735 | 736 | class TestAuth(): 737 | """ 738 | Test authentication 739 | """ 740 | def POST(self): 741 | """ 742 | Test authentication 743 | """ 744 | # LDAP authentication 745 | is_auth, message = tools.ldap_authentification(SERVER_OPTS) 746 | if not is_auth: 747 | return tools.response_render(message, http_code='401 Unauthorized') 748 | return tools.response_render('OK') 749 | 750 | class MyApplication(web.application): 751 | """ 752 | Can change port or other stuff 753 | """ 754 | def run(self, port=int(SERVER_OPTS['port']), *middleware): 755 | func = self.wsgifunc(*middleware) 756 | return web.httpserver.runsimple(func, ('0.0.0.0', port)) 757 | 758 | if __name__ == "__main__": 759 | if SERVER_OPTS['ssl']: 760 | HTTPServer.ssl_adapter = BuiltinSSLAdapter( 761 | certificate=SERVER_OPTS['ssl_public_key'], 762 | private_key=SERVER_OPTS['ssl_private_key']) 763 | if ARGS.verbose: 764 | print('SSL: %s' % SERVER_OPTS['ssl']) 765 | print('LDAP: %s' % SERVER_OPTS['ldap']) 766 | print('Admin DB Failover: %s' % SERVER_OPTS['admin_db_failover']) 767 | APP = MyApplication(constants.URLS, globals(), autoreload=False) 768 | web.config.debug = SERVER_OPTS['debug'] 769 | if SERVER_OPTS['debug']: 770 | print('Debug mode on') 771 | APP.run() 772 | --------------------------------------------------------------------------------