├── .gitignore ├── LICENSE ├── NOTICE ├── README.md ├── config └── config-sample.ini ├── core.php ├── email.php ├── extensions └── README ├── ldap.php ├── migrations ├── 001.php ├── 002.php └── 003.php ├── model ├── access.php ├── accessoption.php ├── accessrequest.php ├── dbdirectory.php ├── entity.php ├── entityevent.php ├── eventdirectory.php ├── group.php ├── groupdirectory.php ├── groupevent.php ├── migrationdirectory.php ├── publickey.php ├── publickeydestrule.php ├── publickeydirectory.php ├── publickeysignature.php ├── record.php ├── server.php ├── serveraccount.php ├── serveraccountdirectory.php ├── serveraccountevent.php ├── serverdirectory.php ├── serverevent.php ├── serverldapaccessoption.php ├── servernote.php ├── syncrequest.php ├── syncrequestdirectory.php ├── user.php ├── useralert.php ├── userdirectory.php └── userevent.php ├── pagesection.php ├── public_html ├── bootstrap │ ├── css │ │ ├── bootstrap-theme.css │ │ ├── bootstrap-theme.css.map │ │ ├── bootstrap-theme.min.css │ │ ├── bootstrap-theme.min.css.map │ │ ├── bootstrap.css │ │ ├── bootstrap.css.map │ │ ├── bootstrap.min.css │ │ └── bootstrap.min.css.map │ ├── fonts │ │ ├── glyphicons-halflings-regular.eot │ │ ├── glyphicons-halflings-regular.svg │ │ ├── glyphicons-halflings-regular.ttf │ │ ├── glyphicons-halflings-regular.woff │ │ └── glyphicons-halflings-regular.woff2 │ └── js │ │ ├── bootstrap.js │ │ ├── bootstrap.min.js │ │ └── npm.js ├── extra.js ├── header.js ├── init.php ├── jquery │ └── jquery-3.7.1.min.js ├── key.png ├── logo-header-opera.png ├── putty-key-generator.png ├── screenshot-account.png ├── screenshot-activity.png ├── screenshot-getting-started.png ├── screenshot-home.png ├── screenshot-servers.png └── style.css ├── requesthandler.php ├── router.php ├── routes.php ├── scripts ├── ldap_update.php ├── pubkey_update.php ├── sync-common.php ├── sync.php └── syncd.php ├── services ├── README ├── init.d │ └── keys-sync └── systemd │ └── keys-sync.service ├── templates ├── access_options.php ├── activity.php ├── base.php ├── bulk_mail.php ├── bulk_mail_choose.php ├── csrf.php ├── entity_pubkeys_json.php ├── entity_pubkeys_txt.php ├── error403.php ├── error404.php ├── error500.php ├── error503.php ├── functions.php ├── group.php ├── group_json.php ├── group_not_found.php ├── groups.php ├── help.php ├── home.php ├── invalid_group_name.php ├── invalid_hostname.php ├── key_upload_fail.php ├── not_admin.php ├── pubkey.php ├── pubkey_json.php ├── pubkey_txt.php ├── pubkeys.php ├── pubkeys_json.php ├── server.php ├── server_account_not_found.php ├── server_json.php ├── server_not_found.php ├── server_sync_status_json.php ├── serveraccount.php ├── serveraccount_sync_status_json.php ├── servers.php ├── servers_json.php ├── signature_upload_fail.php ├── tools.php ├── unique_key_violation.php ├── user.php ├── user_not_found.php ├── user_pubkeys.php └── users.php └── views ├── access_options.php ├── activity.php ├── bulk_mail.php ├── csrf.php ├── error403.php ├── error404.php ├── error500.php ├── error503.php ├── group.php ├── groups.php ├── help.php ├── home.php ├── pubkey.php ├── pubkeys.php ├── server.php ├── server_sync_status.php ├── serveraccount.php ├── serveraccount_pubkeys.php ├── serveraccount_sync_status.php ├── servers.php ├── tools.php ├── user.php ├── user_pubkeys.php └── users.php /.gitignore: -------------------------------------------------------------------------------- 1 | config/config.ini 2 | config/keys-sync 3 | config/keys-sync.pub 4 | extensions/*.php 5 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | NOTICE 2 | 3 | Copyright 2013-2017 Opera Software AS 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | 18 | THIRD PARTY ACKNOWLEDGEMENTS 19 | 20 | Component: Bootstrap Framework 21 | 22 | The MIT License (MIT) 23 | 24 | Copyright (c) 2011-2017 Twitter, Inc. 25 | Copyright (c) 2011-2017 The Bootstrap Authors 26 | 27 | Permission is hereby granted, free of charge, to any person obtaining a copy 28 | of this software and associated documentation files (the "Software"), to deal 29 | in the Software without restriction, including without limitation the rights 30 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 31 | copies of the Software, and to permit persons to whom the Software is 32 | furnished to do so, subject to the following conditions: 33 | 34 | The above copyright notice and this permission notice shall be included in 35 | all copies or substantial portions of the Software. 36 | 37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 38 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 39 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 40 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 41 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 42 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 43 | THE SOFTWARE. 44 | 45 | Component: jQuery Javascript Library 46 | 47 | Copyright JS Foundation and other contributors, https://js.foundation/ 48 | 49 | This software consists of voluntary contributions made by many 50 | individuals. For exact contribution history, see the revision history 51 | available at https://github.com/jquery/jquery 52 | 53 | The following license applies to all parts of this software except as 54 | documented below: 55 | 56 | ==== 57 | 58 | Permission is hereby granted, free of charge, to any person obtaining 59 | a copy of this software and associated documentation files (the 60 | "Software"), to deal in the Software without restriction, including 61 | without limitation the rights to use, copy, modify, merge, publish, 62 | distribute, sublicense, and/or sell copies of the Software, and to 63 | permit persons to whom the Software is furnished to do so, subject to 64 | the following conditions: 65 | 66 | The above copyright notice and this permission notice shall be 67 | included in all copies or substantial portions of the Software. 68 | 69 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 70 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 71 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 72 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 73 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 74 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 75 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 76 | 77 | ==== 78 | 79 | All files located in the node_modules and external directories are 80 | externally maintained libraries used by this software which have their 81 | own licenses; we recommend you read them, as their terms may differ from 82 | the terms above. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SKA - SSH Key Authority 2 | ======================= 3 | 4 | *Please see the [Security Advisories](#security-advisories) section below for a recently addressed security issue* 5 | 6 | A tool for managing user and server SSH access to any number of servers. 7 | 8 | Features 9 | -------- 10 | 11 | * Easily manage SSH key access for all accounts on your servers. 12 | * Manage user access and server-to-server access rules. 13 | * Integrate with your LDAP directory service for user authorization. 14 | * Automatically remove server access from people when they leave your team. 15 | * Provides an easy interface for your users to upload their public keys. 16 | * Designate server administrators and let them manage access to their own server. 17 | * Create group-based access rules for easier management. 18 | * Specify SSH access options such as `command=`, `nopty` etc on your access rules. 19 | * All access changes are logged to the database and to the system logs. Granting of access is also reported by email. 20 | * Be notified when a server becomes orphaned (has no active administrators). 21 | 22 | Demo 23 | ---- 24 | 25 | You can view the SSH Key Authority in action on the [demonstration server](https://ska.xiven.com/). 26 | 27 | Use one of the following sets of username / password credentials to log in: 28 | 29 | * testuser / testuser - normal user with admin access granted to a few servers 30 | * testadmin / testadmin - admin user 31 | 32 | All data on this demonstration server is reset nightly at 00:00 UTC. 33 | 34 | Requirements 35 | ------------ 36 | 37 | * An LDAP directory service 38 | * Apache 2.2 or higher 39 | * PHP 5.6 or higher 40 | * PHP JSON extension 41 | * PHP LDAP extension 42 | * PHP mbstring (Multibyte String) extension 43 | * PHP MySQL extension 44 | * PHP ssh2 extension 45 | * MySQL (5.5+), Percona Server (5.5+) or MariaDB database 46 | 47 | Installation 48 | ------------ 49 | 50 | 1. Clone the repo somewhere outside of your default Apache document root. 51 | 52 | 2. Add the following directives to your Apache configuration (eg. virtual host config): 53 | 54 | DocumentRoot /path/to/ska/public_html 55 | DirectoryIndex init.php 56 | FallbackResource /init.php 57 | 58 | 3. Create a MySQL user and database (run in MySQL shell): 59 | 60 | CREATE USER 'ska-user'@'localhost' IDENTIFIED BY 'password'; 61 | CREATE DATABASE `ska-db` DEFAULT CHARACTER SET utf8mb4; 62 | GRANT ALL ON `ska-db`.* to 'ska-user'@'localhost'; 63 | 64 | 4. Copy the file `config/config-sample.ini` to `config/config.ini` and edit the settings as required. 65 | 66 | 5. Set up authnz_ldap for your virtual host (or any other authentication module that will pass on an Auth-user 67 | variable to the application). 68 | 69 | 6. Set `scripts/ldap_update.php` to run on a regular cron job. 70 | 71 | 7. Generate an SSH key pair to synchronize with. SSH Key Authority will expect to find the files as `config/keys-sync` and `config/keys-sync.pub` 72 | for the private and public keys respectively. The key must be in `pem` format. The following command will generate the key in the required format: 73 | 74 | ssh-keygen -t rsa -b 4096 -m PEM -C 'comment' -f config/keys-sync 75 | 76 | 8. Install the SSH key synchronization daemon. For systemd: 77 | 78 | 1. Copy `services/systemd/keys-sync.service` to `/etc/systemd/system/` 79 | 2. Modify `ExecStart` path and `User` as necessary. If SSH Key Authority is installed under `/home`, disable `ProtectHome`. 80 | 3. `systemctl daemon-reload` 81 | 4. `systemctl enable keys-sync.service` 82 | 83 | for sysv-init: 84 | 85 | 1. Copy `services/init.d/keys-sync` to `/etc/init.d/` 86 | 2. Modify `SCRIPT` path and `USER` as necessary. 87 | 3. `update-rc.d keys-sync defaults` 88 | 89 | Usage 90 | ----- 91 | 92 | Anyone in the LDAP group defined under `admin_group_cn` in `config/config.ini` will be able to manage accounts and servers. 93 | 94 | Key distribution 95 | ---------------- 96 | 97 | SSH Key Authority distributes authorized keys to your servers via SSH. It does this by: 98 | 99 | 1. Connecting to the server with SSH, authorizing as the `keys-sync` user. 100 | 2. Writing the appropriate authorized keys to named user files in `/var/local/keys-sync/` (eg. all authorized keys for the root user will be written to `/var/local/keys-sync/root`). 101 | 102 | This means that your SSH installation will need to be reconfigured to read authorized keys from `/var/local/keys-sync/`. 103 | 104 | Please note that doing so will deny access to any existing SSH public key authorized in the default `~/.ssh` directories. 105 | 106 | Under OpenSSH, the configuration changes needed are: 107 | 108 | AuthorizedKeysFile /var/local/keys-sync/%u 109 | StrictModes no 110 | 111 | StrictModes must be disabled because the files will all be owned by the keys-sync user. 112 | 113 | The file `/var/local/keys-sync/keys-sync` must exist, with the same contents as the `config/keys-sync.pub` file in order for the synchronization daemon to authenticate. 114 | 115 | Screenshots 116 | ----------- 117 | 118 | ### Homepage overview 119 |  120 | 121 | ### Server listing 122 |  123 | 124 | ### Server account access management 125 |  126 | 127 | ### Activity log 128 |  129 | 130 | ### Getting started guide for new users 131 |  132 | 133 | Security advisories 134 | ------------------- 135 | * [SKA security advisory: SSH port redirection attack](https://github.com/operasoftware/ssh-key-authority/wiki/SKA-security-advisory%3A-SSH-port-redirection-attack) 136 | 137 | License 138 | ------- 139 | 140 | Copyright 2013-2017 Opera Software 141 | 142 | Licensed under the Apache License, Version 2.0 (the "License"); 143 | you may not use this file except in compliance with the License. 144 | You may obtain a copy of the License at 145 | 146 | http://www.apache.org/licenses/LICENSE-2.0 147 | 148 | Unless required by applicable law or agreed to in writing, software 149 | distributed under the License is distributed on an "AS IS" BASIS, 150 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 151 | See the License for the specific language governing permissions and 152 | limitations under the License. 153 | -------------------------------------------------------------------------------- /config/config-sample.ini: -------------------------------------------------------------------------------- 1 | ; SSH Key Authority config file 2 | [web] 3 | enabled = 1 4 | baseurl = https://ska.example.com 5 | logo = /logo-header-opera.png 6 | ; footer may contain HTML. Literal & " < and > should be escaped as & 7 | ; " < $gt; 8 | footer = 'Developed by Opera Software.' 9 | 10 | [general] 11 | ; Use timeout --version to find out the current version 12 | ; used on e.g. debian 13 | timeout_util = GNU coreutils 14 | ; used on e.g. alpine 15 | ; timeout_util = BusyBox 16 | 17 | [security] 18 | ; It is important that SKA is able to verify that it has connected to the 19 | ; server that it expected to connect to (otherwise it could be tricked into 20 | ; syncing the wrong keys to a server). The simplest way to accomplish this is 21 | ; through SSH host key verification. Setting either of the 2 options below to 22 | ; '0' can weaken the protection that SSH host key verification provides. 23 | 24 | ; Determine who can reset a server's SSH host key in SKA: 25 | ; 0: Allow server admins to reset the SSH host key for servers that they 26 | ; administer 27 | ; 1: Full SKA admin access is required to reset a server's host key 28 | host_key_reset_restriction = 1 29 | 30 | ; Determine what happens if multiple servers have the same SSH host key: 31 | ; 0: Allow sync to proceed 32 | ; 1: Abort sync of affected servers and report an error 33 | ; It is not recommended to leave this set to '0' indefinitely 34 | host_key_collision_protection = 1 35 | 36 | 37 | ; Hostname verification is a supplement to SSH host key verification for 38 | ; making sure that the sync process has connected to the server that it 39 | ; expected to. 40 | 41 | ; Determine how hostname verification is performed: 42 | ; 0: Do not perform hostname verification 43 | ; 1: Compare with the result of `hostname -f` 44 | ; 2: Compare with /var/local/keys-sync/.hostnames, fall back to `hostname -f` 45 | ; if the file does not exist 46 | ; 3: Compare with /var/local/keys-sync/.hostnames, abort sync if the file 47 | ; does not exist 48 | ; The last option provides the most solid verification, as a server will only 49 | ; be synced to if it has been explicitly allowed on the server itself. 50 | hostname_verification = 0 51 | 52 | [defaults] 53 | ; This setting will cause new servers to always have a managed account called 54 | ; "root" and for that account to be automatically added into the 55 | ; "root-accounts" group: 56 | ; 57 | ; account_groups[root] = "root-accounts" 58 | ; 59 | ; Any number of these can be specified 60 | account_groups[root] = "root-accounts" 61 | 62 | [email] 63 | enabled = 1 64 | ; The mail address that outgoing mails will be sent from 65 | from_address = ska@example.com 66 | from_name = "SSH Key Authority system" 67 | ; Where to mail security notifications to 68 | report_address = reports@example.com 69 | report_name = "SSH Key Authority reports" 70 | ; Where users should contact for help 71 | admin_address = admin@example.com 72 | admin_name = "SSH Key Authority administrators" 73 | ; You can use the reroute directive to redirect all outgoing mail to a single 74 | ; mail address - typically for temporary testing purposes 75 | ;reroute = test@example.com 76 | 77 | [database] 78 | ; Connection details to the MySQL database 79 | hostname = localhost 80 | port = 3306 81 | username = ska-user 82 | password = password 83 | database = ska-db 84 | 85 | [ldap] 86 | ; Address to connect to LDAP server 87 | host = ldaps://ldap.example.com:636 88 | ; Use StartTLS for connection security (recommended if using ldap:// instead 89 | ; of ldaps:// above) 90 | starttls = 0 91 | ; LDAP subtree containing USER entries 92 | dn_user = "ou=users,dc=example,dc=com" 93 | ; LDAP subtree containing GROUP entries 94 | dn_group = "ou=groups,dc=example,dc=com" 95 | ; (Optional) filter for matching user objects 96 | ;user_filter = "(objectClass=inetOrgPerson)" 97 | ; (Optional) filter for matching group objects 98 | ;group_filter = "(objectClass=posixGroup)" 99 | 100 | ; Set to 1 if the LDAP library should process referrals. In most cases this 101 | ; is not needed, and for AD servers it can cause errors when querying the 102 | ; whole tree. 103 | follow_referrals = 0 104 | 105 | ; Leave bind_dn empty if binding is not required 106 | bind_dn = 107 | bind_password = 108 | 109 | ; User attributes 110 | user_id = uid 111 | user_name = cn 112 | user_email = mail 113 | ;user_superior = superioremployee 114 | 115 | ; If inactive users exist in your LDAP directory, filter with the following 116 | ; settings: 117 | ; Field to filter on: 118 | ;user_active = organizationalstatus 119 | ; Use *one* of user_active_true or user_active_false 120 | ; user_active_true means user is active if the user_active field equals its 121 | ; value 122 | ;user_active_true = 'current' 123 | ; user_active_false means user is active if the user_active field does not 124 | ; equal its value 125 | ;user_active_false = 'former' 126 | 127 | ; Group membership attributes. Examples below are for typical setups: 128 | ; 129 | ; POSIX groups 130 | ; group_member = memberUid 131 | ; group_member_value = uid 132 | ; 133 | ; Group-of-names groups 134 | ; group_member = member 135 | ; group_member_value = dn 136 | ; 137 | ; Attribute of group where members are stored 138 | group_member = memberUid 139 | ; User attribute to compare with 140 | group_member_value = uid 141 | 142 | ; Members of admin_group are given full admin access to SSH Key Authority web 143 | ; interface 144 | admin_group_cn = ska-administrators 145 | 146 | ; Other LDAP groups that should have their memberships synced 147 | ;sync_groups[] = ldap_group_name 148 | 149 | [inventory] 150 | ; SSH Key Authority will read the contents of the file /etc/uuid (if it 151 | ; exists) when syncing with a server. If a value is found, it can be used as a 152 | ; link to an inventory system. 153 | ; %s in the url directive will be replaced with the value found in /etc/uuid 154 | ;url = "https://inventory.example.com/device/%s" 155 | 156 | [gpg] 157 | ; SSH Key Authority can GPG sign outgoing emails sent from the 158 | ; email.from_address. To do this it needs to know an appropriate key ID to use 159 | ;key_id = 0123456789ABCDEF0123456789ABCDEF01234567 160 | -------------------------------------------------------------------------------- /extensions/README: -------------------------------------------------------------------------------- 1 | For extending or replacing functionality. 2 | All .php files in this directory are automatically include()'d. -------------------------------------------------------------------------------- /ldap.php: -------------------------------------------------------------------------------- 1 | conn = null; 28 | $this->host = $host; 29 | $this->starttls = $starttls; 30 | $this->bind_dn = $bind_dn; 31 | $this->bind_password = $bind_password; 32 | $this->options = $options; 33 | } 34 | 35 | private function connect() { 36 | $this->conn = ldap_connect($this->host); 37 | if($this->conn === false) throw new LDAPConnectionFailureException('Invalid LDAP connection settings'); 38 | if($this->starttls) { 39 | if(!ldap_start_tls($this->conn)) throw new LDAPConnectionFailureException('Could not initiate TLS connection to LDAP server'); 40 | } 41 | foreach($this->options as $option => $value) { 42 | ldap_set_option($this->conn, $option, $value); 43 | } 44 | if(!empty($this->bind_dn)) { 45 | if(!ldap_bind($this->conn, $this->bind_dn, $this->bind_password)) throw new LDAPConnectionFailureException('Could not bind to LDAP server'); 46 | } 47 | } 48 | 49 | public function search($basedn, $filter, $fields = array(), $sort = array()) { 50 | if(is_null($this->conn)) $this->connect(); 51 | if(empty($fields)) $r = @ldap_search($this->conn, $basedn, $filter); 52 | else $r = @ldap_search($this->conn, $basedn, $filter, $fields); 53 | $sort = array_reverse($sort); 54 | foreach($sort as $field) { 55 | @ldap_sort($this->conn, $r, $field); 56 | } 57 | if($r) { 58 | // Fetch entries 59 | $result = @ldap_get_entries($this->conn, $r); 60 | unset($result['count']); 61 | $items = array(); 62 | foreach($result as $item) { 63 | unset($item['count']); 64 | $itemResult = array(); 65 | foreach($item as $key => $values) { 66 | if(!is_int($key)) { 67 | if(is_array($values)) { 68 | unset($values['count']); 69 | if(count($values) == 1) $values = $values[0]; 70 | } 71 | $itemResult[$key] = $values; 72 | } 73 | } 74 | $items[] = $itemResult; 75 | } 76 | return $items; 77 | } 78 | return false; 79 | } 80 | 81 | public static function escape($str = '') { 82 | $metaChars = array("\\00", "\\", "(", ")", "*"); 83 | $quotedMetaChars = array(); 84 | foreach($metaChars as $key => $value) { 85 | $quotedMetaChars[$key] = '\\'. dechex(ord($value)); 86 | } 87 | $str = str_replace($metaChars, $quotedMetaChars, $str); 88 | return $str; 89 | } 90 | } 91 | 92 | class LDAPConnectionFailureException extends RuntimeException {} 93 | -------------------------------------------------------------------------------- /migrations/001.php: -------------------------------------------------------------------------------- 1 | database->query(" 5 | CREATE TABLE `migration` ( 6 | `id` int(10) unsigned NOT NULL, 7 | `name` text NOT NULL, 8 | `applied` datetime NOT NULL 9 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 10 | "); 11 | -------------------------------------------------------------------------------- /migrations/003.php: -------------------------------------------------------------------------------- 1 | database->query(" 5 | ALTER TABLE `server` ADD COLUMN `port` int(10) unsigned NOT NULL DEFAULT 22 6 | "); 7 | -------------------------------------------------------------------------------- /model/access.php: -------------------------------------------------------------------------------- 1 | id)) throw new BadMethodCallException('Access rule must be in directory before options can be added'); 34 | $stmt = $this->database->prepare("INSERT INTO access_option SET access_id = ?, `option` = ?, value = ?"); 35 | $stmt->bind_param('dss', $this->id, $option->option, $option->value); 36 | $stmt->execute(); 37 | $stmt->close(); 38 | } 39 | 40 | /** 41 | * Remove an SSH option from the access rule 42 | * @param AccessOption $option to be removed 43 | */ 44 | public function delete_option(AccessOption $option) { 45 | if(is_null($this->id)) throw new BadMethodCallException('Access rule must be in directory before options can be deleted'); 46 | $stmt = $this->database->prepare("DELETE FROM access_option WHERE access_id = ? AND `option` = ?"); 47 | $stmt->bind_param('ds', $this->id, $option->option); 48 | $stmt->execute(); 49 | $stmt->close(); 50 | } 51 | 52 | /** 53 | * Replace the current list of SSH access options with the provided array of options. 54 | * This is a crude implementation - just deletes all existing options and adds new ones, with 55 | * table locking for a small measure of safety. 56 | * @param array $options array of AccessOption objects 57 | */ 58 | public function update_options(array $options) { 59 | $stmt = $this->database->query("LOCK TABLES access_option WRITE"); 60 | $oldoptions = $this->list_options(); 61 | foreach($oldoptions as $oldoption) { 62 | $this->delete_option($oldoption); 63 | } 64 | foreach($options as $option) { 65 | $this->add_option($option); 66 | } 67 | $stmt = $this->database->query("UNLOCK TABLES"); 68 | $this->dest_entity->sync_access(); 69 | } 70 | 71 | /** 72 | * List all current SSH access options applied to the access rule. 73 | * @return array of AccessOption objects 74 | */ 75 | public function list_options() { 76 | if(is_null($this->id)) throw new BadMethodCallException('Access rule must be in directory before options can be listed'); 77 | $stmt = $this->database->prepare(" 78 | SELECT * 79 | FROM access_option 80 | WHERE access_id = ? 81 | ORDER BY `option` 82 | "); 83 | $stmt->bind_param('d', $this->id); 84 | $stmt->execute(); 85 | $result = $stmt->get_result(); 86 | $options = array(); 87 | while($row = $result->fetch_assoc()) { 88 | $options[$row['option']] = new AccessOption($row['option'], $row); 89 | } 90 | $stmt->close(); 91 | return $options; 92 | } 93 | } 94 | 95 | class AccessNotFoundException extends Exception {} 96 | -------------------------------------------------------------------------------- /model/accessoption.php: -------------------------------------------------------------------------------- 1 | database = $database; 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /model/entityevent.php: -------------------------------------------------------------------------------- 1 | data['actor_id']); 38 | return $actor; 39 | default: 40 | return parent::__get($field); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /model/eventdirectory.php: -------------------------------------------------------------------------------- 1 | array("se.id", "se.server_id", "NULL as `entity_id`", "se.actor_id", "se.date", "se.details"), 33 | 'group' => array("ee.id", "NULL AS server_id", "ee.entity_id", "ee.actor_id", "ee.date", "ee.details") 34 | ); 35 | $joins = array('server' => array(), 'group' => array()); 36 | $where = array('server' => array(), 'group' => array()); 37 | foreach($filter as $field => $value) { 38 | if($value) { 39 | switch($field) { 40 | case 'admin': 41 | // Filter for events from servers that the user is an admin of 42 | $joins['server']['adminsearch'] = "INNER JOIN server_admin AS admin_search ON admin_search.server_id = se.server_id"; 43 | $where['server'][] = "admin_search.entity_id = ".intval($value); 44 | // Filter for events from server accounts or groups that the user is an admin of 45 | // (possibly indirectly for the former as a result of being server admin) 46 | $joins['group']['adminsearch'] = "LEFT JOIN entity_admin AS admin_search ON admin_search.entity_id = ee.entity_id"; 47 | $joins['group']['account'] = "LEFT JOIN server_account AS sa ON sa.entity_id = ee.entity_id"; 48 | $joins['group']['server'] = "LEFT JOIN server AS s ON s.id = sa.server_id"; 49 | $joins['group']['parentadminsearch'] = "LEFT JOIN server_admin AS parent_admin_search ON parent_admin_search.server_id = s.id"; 50 | $where['group'][] = "admin_search.admin = ".intval($value)." OR parent_admin_search.entity_id = ".intval($value); 51 | break; 52 | } 53 | } 54 | } 55 | $stmt = $this->database->prepare(" 56 | (SELECT ".implode(", ", $fields['server']).", 'server' AS event_type 57 | FROM server_event se ".implode(" ", $joins['server'])." 58 | ".(count($where['server']) == 0 ? "" : "WHERE (".implode(") AND (", $where['server']).")")." 59 | GROUP BY se.id 60 | ORDER BY se.id DESC) 61 | UNION 62 | (SELECT ".implode(", ", $fields['group']).", e.type AS event_type 63 | FROM entity_event ee ".implode(" ", $joins['group'])." 64 | INNER JOIN entity e ON e.id = ee.entity_id 65 | ".(count($where['group']) == 0 ? "" : "WHERE (".implode(") AND (", $where['group']).")")." 66 | GROUP BY ee.id 67 | ORDER BY ee.id DESC) 68 | ORDER BY `date` DESC, id DESC 69 | ".(is_null($limit) ? '' : 'LIMIT '.intval($limit))." 70 | "); 71 | $stmt->execute(); 72 | $result = $stmt->get_result(); 73 | $events = array(); 74 | while($row = $result->fetch_assoc()) { 75 | if($row['event_type'] == 'server') { 76 | $events[] = new ServerEvent($row['id'], $row); 77 | } elseif($row['event_type'] == 'user') { 78 | $events[] = new UserEvent($row['id'], $row); 79 | } elseif($row['event_type'] == 'server account') { 80 | $events[] = new ServerAccountEvent($row['id'], $row); 81 | } elseif($row['event_type'] == 'group') { 82 | $events[] = new GroupEvent($row['id'], $row); 83 | } 84 | } 85 | $stmt->close(); 86 | return $events; 87 | } 88 | } 89 | 90 | class EventNotFoundException extends Exception {} 91 | -------------------------------------------------------------------------------- /model/groupevent.php: -------------------------------------------------------------------------------- 1 | data['entity_id']); 31 | return $group; 32 | default: 33 | return parent::__get($field); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /model/migrationdirectory.php: -------------------------------------------------------------------------------- 1 | database->prepare('SELECT MAX(id) FROM migration'); 31 | $stmt->execute(); 32 | $result = $stmt->get_result(); 33 | list($current_migration) = $result->fetch_row(); 34 | } catch(mysqli_sql_exception $e) { 35 | if($e->getCode() === 1146) { 36 | $current_migration = 0; 37 | } else { 38 | throw $e; 39 | } 40 | } 41 | if($current_migration < self::LAST_MIGRATION) { 42 | $this->apply_pending_migrations($current_migration); 43 | } 44 | } 45 | 46 | private function apply_pending_migrations($current_migration) { 47 | openlog('dnsui', LOG_ODELAY, LOG_USER); 48 | for($migration_id = $current_migration + 1; $migration_id <= self::LAST_MIGRATION; $migration_id++) { 49 | $filename = str_pad($migration_id, 3, '0', STR_PAD_LEFT).'.php'; 50 | syslog(LOG_INFO, "migration={$filename};object=database;action=apply"); 51 | $migration_name = $filename; 52 | include('migrations/'.$filename); 53 | $stmt = $this->database->prepare('INSERT INTO migration VALUES (?, ?, NOW())'); 54 | $stmt->bind_param('ds', $migration_id, $migration_name); 55 | $stmt->execute(); 56 | } 57 | closelog(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /model/publickeydestrule.php: -------------------------------------------------------------------------------- 1 | database->prepare(" 30 | SELECT public_key.*, entity.type AS entity_type 31 | FROM public_key 32 | INNER JOIN entity ON entity.id = public_key.entity_id 33 | WHERE public_key.id = ? 34 | "); 35 | $stmt->bind_param('d', $id); 36 | $stmt->execute(); 37 | $result = $stmt->get_result(); 38 | if($row = $result->fetch_assoc()) { 39 | switch($row['entity_type']) { 40 | case 'user': $row['owner'] = new User($row['entity_id']); break; 41 | case 'server account': $row['owner'] = new ServerAccount($row['entity_id']); break; 42 | } 43 | $key = new PublicKey($row['id'], $row); 44 | } else { 45 | throw new PublicKeyNotFoundException('Public key does not exist.'); 46 | } 47 | $stmt->close(); 48 | return $key; 49 | } 50 | 51 | /** 52 | * List stored public keys, optionally filtered by various parameters. 53 | * See also Entity::list_public_keys function for retrieving keys belonging to a specific entity. 54 | * @param array $include list of extra data to include in response - currently unused 55 | * @param array $filter list of field/value pairs to filter results on 56 | * @return array of PublicKey objects 57 | */ 58 | public function list_public_keys($include = array(), $filter = array()) { 59 | // WARNING: The search query is not parameterized - be sure to properly escape all input 60 | $fields = array("public_key.*, entity.type AS entity_type"); 61 | $joins = array(); 62 | $where = array(); 63 | foreach($filter as $field => $value) { 64 | if($value) { 65 | switch($field) { 66 | case 'type': 67 | $where[] = "public_key.type = '".$this->database->escape_string($value)."'"; 68 | break; 69 | case 'keysize-min': 70 | $where[] = "public_key.keysize >= ".intval($this->database->escape_string($value)); 71 | break; 72 | case 'keysize-max': 73 | $where[] = "public_key.keysize <= ".intval($this->database->escape_string($value)); 74 | break; 75 | case 'fingerprint': 76 | $where[] = "public_key.fingerprint_md5 = '".$this->database->escape_string($value)."' OR public_key.fingerprint_sha256 = '".$this->database->escape_string($value)."'"; 77 | break; 78 | } 79 | } 80 | } 81 | $stmt = $this->database->prepare(" 82 | SELECT ".implode(", ", $fields)." 83 | FROM public_key ".implode(" ", $joins)." 84 | INNER JOIN entity ON entity.id = public_key.entity_id 85 | LEFT JOIN user ON user.entity_id = entity.id 86 | LEFT JOIN server_account ON server_account.entity_id = entity.id 87 | LEFT JOIN server ON server.id = server_account.server_id 88 | ".(count($where) == 0 ? "" : "WHERE (".implode(") AND (", $where).")")." 89 | ORDER BY entity.type, user.uid, server.hostname, server_account.name 90 | "); 91 | $stmt->execute(); 92 | $result = $stmt->get_result(); 93 | $pubkeys = array(); 94 | while($row = $result->fetch_assoc()) { 95 | switch($row['entity_type']) { 96 | case 'user': $row['owner'] = new User($row['entity_id']); break; 97 | case 'server account': $row['owner'] = new ServerAccount($row['entity_id']); break; 98 | } 99 | $pubkeys[] = new PublicKey($row['id'], $row); 100 | } 101 | return $pubkeys; 102 | } 103 | } 104 | 105 | class PublicKeyNotFoundException extends Exception {} 106 | -------------------------------------------------------------------------------- /model/publickeysignature.php: -------------------------------------------------------------------------------- 1 | verify($this->public_key->export().$line_ending, $this->signature); 36 | if(is_array($info)) { 37 | $sig = reset($info); 38 | if($sig['validity'] > 0) break; 39 | } else { 40 | throw new InvalidArgumentException("Signature doesn't seem valid"); 41 | } 42 | } 43 | if($sig['validity'] == 0) { 44 | #throw new InvalidArgumentException("Signature doesn't validate against pubkey"); 45 | } 46 | $this->fingerprint = $sig['fingerprint']; 47 | $this->sign_date = gmdate('Y-m-d H:i:s', $sig['timestamp']); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /model/record.php: -------------------------------------------------------------------------------- 1 | database = &$database; 56 | $this->active_user = &$active_user; 57 | $this->id = $id; 58 | $this->data = array(); 59 | foreach($preload_data as $field => $value) { 60 | $this->data[$field] = $value; 61 | } 62 | if(is_null($this->id)) $this->dirty = true; 63 | } 64 | 65 | /** 66 | * Magic getter method - return the value of the specified field. Retrieve the row from the 67 | * database if we do not have data for that field yet. 68 | * @param string $field name of field to retrieve 69 | * @return mixed data stored in field 70 | * @throws Exception if the row or the field does not exist in the database 71 | */ 72 | public function &__get($field) { 73 | if(!array_key_exists($field, $this->data)) { 74 | // We don't have a value for this field yet 75 | if(is_null($this->id)) { 76 | // Record is not yet in the database - nothing to retrieve 77 | $result = null; 78 | return $result; 79 | } 80 | // Attempt to get data from database 81 | $stmt = $this->database->prepare("SELECT * FROM `$this->table` WHERE {$this->idfield} = ?"); 82 | $stmt->bind_param('d', $this->id); 83 | $stmt->execute(); 84 | 85 | $result = $stmt->get_result(); 86 | if($result->num_rows != 1) { 87 | throw new Exception("Unexpected number of rows returned ({$result->num_rows}), expected exactly 1. Table:{$this->table}, ID field: {$this->idfield}, ID: {$this->id}"); 88 | } 89 | $data = $result->fetch_assoc(); 90 | // Populate data array for fields we do not already have a value for 91 | foreach($data as $f => $v) { 92 | if(!isset($this->data[$f])) { 93 | $this->data[$f] = $v; 94 | } 95 | } 96 | $stmt->close(); 97 | if(!array_key_exists($field, $this->data)) { 98 | // We still don't have a value, so this field doesn't exist in the database 99 | throw new Exception("Field $field does not exist in {$this->table} table."); 100 | } 101 | } 102 | return $this->data[$field]; 103 | } 104 | 105 | /** 106 | * Magic setter method - store the updated value and set the record as dirty. 107 | * @param string $field name of field 108 | * @param mixed $value data to store in field 109 | */ 110 | public function __set($field, $value) { 111 | $this->data[$field] = $value; 112 | $this->dirty = true; 113 | if($field == $this->idfield) $this->id = $value; 114 | } 115 | 116 | /** 117 | * Update the database with all fields that have been modified. 118 | * @return array of StdClass detailing actual updates that were applied 119 | * @throws UniqueKeyViolationException if the update violated a unique key on the table 120 | */ 121 | public function update() { 122 | $stmt = $this->database->prepare("SELECT * FROM `$this->table` WHERE {$this->idfield} = ?"); 123 | $stmt->bind_param('d', $this->id); 124 | $stmt->execute(); 125 | $result = $stmt->get_result(); 126 | if(!($row = $result->fetch_assoc())) { 127 | throw new Exception("Record not found in database"); 128 | } 129 | $stmt->close(); 130 | $updates = array(); 131 | $fields = array(); 132 | $values = array(); 133 | $types = ''; 134 | foreach($row as $field => $value) { 135 | if(array_key_exists($field, $this->data) && $this->data[$field] != $value) { 136 | $update = new StdClass; 137 | $update->field = $field; 138 | $update->old_value = $value; 139 | $update->new_value = $this->data[$field]; 140 | $updates[] = $update; 141 | $fields[] = "`$field` = ?"; 142 | $values[] =& $this->data[$field]; 143 | $types .= 's'; 144 | } 145 | } 146 | if(!empty($updates)) { 147 | try { 148 | $stmt = $this->database->prepare("UPDATE `$this->table` SET ".implode(', ', $fields)." WHERE {$this->idfield} = ?"); 149 | $values[] =& $this->id; 150 | $types .= 'd'; 151 | array_unshift($values, $types); 152 | $reflection = new ReflectionClass('mysqli_stmt'); 153 | $method = $reflection->getMethod("bind_param"); 154 | $method->invokeArgs($stmt, $values); 155 | $stmt->execute(); 156 | } catch(mysqli_sql_exception $e) { 157 | if($e->getCode() == 1062) { 158 | // Duplicate entry 159 | $message = $e->getMessage(); 160 | if(preg_match("/^Duplicate entry '(.*)' for key '(.*)'$/", $message, $matches)) { 161 | $ne = new UniqueKeyViolationException($e->getMessage()); 162 | $ne->fields = explode(',', $matches[2]); 163 | $ne->values = explode(',', $matches[1]); 164 | throw $ne; 165 | } 166 | } 167 | throw $e; 168 | } 169 | } 170 | $this->dirty = false; 171 | return $updates; 172 | } 173 | } 174 | 175 | class UniqueKeyViolationException extends Exception { 176 | /** 177 | * Fields involved in the unique key conflict 178 | */ 179 | public $fields; 180 | /** 181 | * Values that conflicted 182 | */ 183 | public $values; 184 | } 185 | -------------------------------------------------------------------------------- /model/serveraccountdirectory.php: -------------------------------------------------------------------------------- 1 | database->prepare("SELECT * FROM server_account WHERE entity_id = ?"); 31 | $stmt->bind_param('d', $entity_id); 32 | $stmt->execute(); 33 | $result = $stmt->get_result(); 34 | if($row = $result->fetch_assoc()) { 35 | $account = new ServerAccount($row['entity_id'], $row); 36 | } else { 37 | throw new ServerAccountNotFoundException('Server account does not exist.'); 38 | } 39 | $stmt->close(); 40 | return $account; 41 | } 42 | } 43 | 44 | class ServerAccountNotFoundException extends Exception {} 45 | class ServerAccountNotDeletableException extends Exception {} 46 | -------------------------------------------------------------------------------- /model/serveraccountevent.php: -------------------------------------------------------------------------------- 1 | data['entity_id']); 28 | return $group; 29 | default: 30 | return parent::__get($field); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /model/serverdirectory.php: -------------------------------------------------------------------------------- 1 | hostname; 29 | $port = $server->port; 30 | try { 31 | $stmt = $this->database->prepare("INSERT INTO server SET hostname = ?, port = ?"); 32 | $stmt->bind_param('sd', $hostname, $port); 33 | $stmt->execute(); 34 | $server->id = $stmt->insert_id; 35 | $stmt->close(); 36 | $server->log(array('action' => 'Server add')); 37 | $server->add_standard_accounts(); 38 | $server->sync_access(); 39 | } catch(mysqli_sql_exception $e) { 40 | if($e->getCode() == 1062) { 41 | // Duplicate entry 42 | throw new ServerAlreadyExistsException("Server {$server->hostname} already exists"); 43 | } else { 44 | throw $e; 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * Get a server from the database by its ID. 51 | * @param int $id of server 52 | * @return Server with specified ID 53 | * @throws ServerNotFoundException if no server with that ID exists 54 | */ 55 | public function get_server_by_id($server_id) { 56 | $stmt = $this->database->prepare("SELECT * FROM server WHERE id = ?"); 57 | $stmt->bind_param('d', $server_id); 58 | $stmt->execute(); 59 | $result = $stmt->get_result(); 60 | if($row = $result->fetch_assoc()) { 61 | $server = new Server($row['id'], $row); 62 | } else { 63 | throw new ServerNotFoundException('Server does not exist.'); 64 | } 65 | $stmt->close(); 66 | return $server; 67 | } 68 | 69 | /** 70 | * Get a server from the database by its hostname. 71 | * @param string $hostname of server 72 | * @return Server with specified hostname 73 | * @throws ServerNotFoundException if no server with that hostname exists 74 | */ 75 | public function get_server_by_hostname($hostname) { 76 | $stmt = $this->database->prepare("SELECT * FROM server WHERE hostname = ?"); 77 | $stmt->bind_param('s', $hostname); 78 | $stmt->execute(); 79 | $result = $stmt->get_result(); 80 | if($row = $result->fetch_assoc()) { 81 | $server = new Server($row['id'], $row); 82 | } else { 83 | throw new ServerNotFoundException('Server does not exist'); 84 | } 85 | $stmt->close(); 86 | return $server; 87 | } 88 | 89 | /** 90 | * Get a server from the database by its uuid. 91 | * @param string $uuid of server 92 | * @return Server with specified uuid 93 | * @throws ServerNotFoundException if no server with that uuid exists 94 | */ 95 | public function get_server_by_uuid($uuid) { 96 | $stmt = $this->database->prepare("SELECT * FROM server WHERE uuid = ?"); 97 | $stmt->bind_param('s', $uuid); 98 | $stmt->execute(); 99 | $result = $stmt->get_result(); 100 | if($row = $result->fetch_assoc()) { 101 | $server = new Server($row['id'], $row); 102 | } else { 103 | throw new ServerNotFoundException('Server does not exist'); 104 | } 105 | $stmt->close(); 106 | return $server; 107 | } 108 | 109 | /** 110 | * List all servers in the database. 111 | * @param array $include list of extra data to include in response 112 | * @param array $filter list of field/value pairs to filter results on 113 | * @return array of Server objects 114 | */ 115 | public function list_servers($include = array(), $filter = array()) { 116 | // WARNING: The search query is not parameterized - be sure to properly escape all input 117 | $fields = array("server.*"); 118 | $joins = array(); 119 | $where = array('!server.deleted'); 120 | foreach($filter as $field => $value) { 121 | if($value) { 122 | switch($field) { 123 | case 'hostname': 124 | $where[] = "hostname REGEXP '".$this->database->escape_string($value)."'"; 125 | break; 126 | case 'ip_address': 127 | case 'rsa_key_fingerprint': 128 | $where[] = "server.$field = '".$this->database->escape_string($value)."'"; 129 | break; 130 | case 'port': 131 | $where[] = "server.$field = ".intval($value); 132 | break; 133 | case 'admin': 134 | $where[] = "admin_search.entity_id = ".intval($value)." OR admin_search_members.entity_id = ".intval($value); 135 | $joins['adminsearch'] = "LEFT JOIN server_admin AS admin_search ON admin_search.server_id = server.id"; 136 | $joins['adminsearchmembers'] = "LEFT JOIN group_member AS admin_search_members ON admin_search_members.group = admin_search.entity_id"; 137 | break; 138 | case 'authorization': 139 | case 'key_management': 140 | case 'sync_status': 141 | $where[] = "server.$field IN ('".implode("', '", array_map(array($this->database, 'escape_string'), $value))."')"; 142 | break; 143 | } 144 | } 145 | } 146 | foreach($include as $inc) { 147 | switch($inc) { 148 | case 'pending_requests': 149 | $fields[] = "COUNT(DISTINCT access_request.source_entity_id) AS pending_requests"; 150 | $joins['accounts'] = "LEFT JOIN server_account ON server_account.server_id = server.id"; 151 | $joins['requests'] = "LEFT JOIN access_request ON access_request.dest_entity_id = server_account.entity_id"; 152 | break; 153 | case 'admins': 154 | $fields[] = "GROUP_CONCAT(DISTINCT IF(user.uid IS NULL, CONCAT('G:', group.name), CONCAT('U:', user.uid)) SEPARATOR ',') AS admins"; 155 | $joins['admins'] = "LEFT JOIN server_admin ON server_admin.server_id = server.id"; 156 | $joins['adminusers'] = "LEFT JOIN user ON user.entity_id = server_admin.entity_id AND user.active"; 157 | $joins['admingroups'] = "LEFT JOIN `group` ON group.entity_id = server_admin.entity_id"; 158 | break; 159 | } 160 | } 161 | try { 162 | $stmt = $this->database->prepare(" 163 | SELECT ".implode(", ", $fields)." 164 | FROM server ".implode(" ", $joins)." 165 | WHERE (".implode(") AND (", $where).") 166 | GROUP BY server.id 167 | ORDER BY server.hostname 168 | "); 169 | } catch(mysqli_sql_exception $e) { 170 | if($e->getCode() == 1139) { 171 | throw new ServerSearchInvalidRegexpException; 172 | } else { 173 | throw $e; 174 | } 175 | } 176 | 177 | $stmt->execute(); 178 | $result = $stmt->get_result(); 179 | $servers = array(); 180 | while($row = $result->fetch_assoc()) { 181 | $servers[] = new Server($row['id'], $row); 182 | } 183 | $stmt->close(); 184 | usort($servers, function($a, $b) {return strnatcasecmp($a->hostname, $b->hostname);}); 185 | # Reverse domain level sort 186 | #usort($servers, function($a, $b) {return strnatcasecmp(implode('.', array_reverse(explode('.', $a->hostname))), implode('.', array_reverse(explode('.', $b->hostname))));}); 187 | return $servers; 188 | } 189 | } 190 | 191 | class ServerNotFoundException extends Exception {} 192 | class ServerAlreadyExistsException extends Exception {} 193 | class ServerSearchInvalidRegexpException extends Exception {} 194 | -------------------------------------------------------------------------------- /model/serverevent.php: -------------------------------------------------------------------------------- 1 | data['actor_id']); 38 | return $actor; 39 | case 'server': 40 | $server = new Server($this->data['server_id']); 41 | return $server; 42 | default: 43 | return parent::__get($field); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /model/serverldapaccessoption.php: -------------------------------------------------------------------------------- 1 | entity_id = $active_user->entity_id; 31 | } 32 | 33 | /** 34 | * Magic getter method - if server field requested, return Server object that note applies to; 35 | * if user field requested, return User object of the person who wrote the note. 36 | * @param string $field to retrieve 37 | * @return mixed data stored in field 38 | */ 39 | public function &__get($field) { 40 | global $user_dir; 41 | switch($field) { 42 | case 'user': 43 | $user = new User($this->entity_id); 44 | return $user; 45 | case 'server': 46 | $server = new Server($this->server_id); 47 | return $server; 48 | default: 49 | return parent::__get($field); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /model/syncrequest.php: -------------------------------------------------------------------------------- 1 | processing = true; 32 | $this->update(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /model/syncrequestdirectory.php: -------------------------------------------------------------------------------- 1 | database->prepare("INSERT IGNORE INTO sync_request SET server_id = ?, account_name = ?"); 33 | $stmt->bind_param('ds', $req->server_id, $req->account_name); 34 | $stmt->execute(); 35 | $req->id = $stmt->insert_id; 36 | $stmt->close(); 37 | } 38 | 39 | /** 40 | * Delete the sync request from the database. 41 | * @param SyncRequest $req object to delete 42 | */ 43 | public function delete_sync_request(SyncRequest $req) { 44 | $stmt = $this->database->prepare("DELETE FROM sync_request WHERE id = ?"); 45 | $stmt->bind_param('s', $req->id); 46 | $stmt->execute(); 47 | $stmt->close(); 48 | } 49 | 50 | /** 51 | * List the sync requests stored in the database that are not being processed yet. 52 | * @return array of SyncRequest objects 53 | */ 54 | public function list_pending_sync_requests() { 55 | if(!isset($this->sync_list_stmt)) { 56 | $this->sync_list_stmt = $this->database->prepare("SELECT * FROM sync_request WHERE processing = 0 ORDER BY id"); 57 | } 58 | $this->sync_list_stmt->execute(); 59 | $result = $this->sync_list_stmt->get_result(); 60 | $reqs = array(); 61 | while($row = $result->fetch_assoc()) { 62 | $reqs[] = new SyncRequest($row['id'], $row); 63 | } 64 | return $reqs; 65 | } 66 | } 67 | 68 | class SyncRequestNotFoundException extends Exception {} 69 | -------------------------------------------------------------------------------- /model/useralert.php: -------------------------------------------------------------------------------- 1 | data['class'])) $this->data['class'] = 'success'; 33 | if(!isset($this->data['escaping'])) $this->data['escaping'] = ESC_HTML; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /model/userdirectory.php: -------------------------------------------------------------------------------- 1 | ldap = $ldap; 35 | $this->cache_uid = array(); 36 | } 37 | 38 | /** 39 | * Create the new user in the database. 40 | * @param User $user object to add 41 | */ 42 | public function add_user(User $user) { 43 | $user_id = $user->uid; 44 | $user_name = $user->name; 45 | $user_active = $user->active; 46 | $user_admin = $user->admin; 47 | $user_email = $user->email; 48 | $stmt = $this->database->prepare("INSERT INTO entity SET type = 'user'"); 49 | $stmt->execute(); 50 | $user->entity_id = $stmt->insert_id; 51 | $stmt = $this->database->prepare("INSERT INTO user SET entity_id = ?, uid = ?, name = ?, email = ?, active = ?, admin = ?"); 52 | $stmt->bind_param('dsssdd', $user->entity_id, $user_id, $user_name, $user_email, $user_active, $user_admin); 53 | $stmt->execute(); 54 | $stmt->close(); 55 | } 56 | 57 | /** 58 | * Get a user from the database by its entity ID. 59 | * @param int $entity_id of user 60 | * @return User with specified entity ID 61 | * @throws UserNotFoundException if no user with that entity ID exists 62 | */ 63 | public function get_user_by_id($id) { 64 | $stmt = $this->database->prepare("SELECT * FROM user WHERE entity_id = ?"); 65 | $stmt->bind_param('d', $id); 66 | $stmt->execute(); 67 | $result = $stmt->get_result(); 68 | if($row = $result->fetch_assoc()) { 69 | $user = new User($row['entity_id'], $row); 70 | } else { 71 | throw new UserNotFoundException('User does not exist.'); 72 | } 73 | $stmt->close(); 74 | return $user; 75 | } 76 | 77 | /** 78 | * Get a user from the database by its uid. If it does not exist in the database, retrieve it 79 | * from LDAP and store in the database. 80 | * @param string $uid of user 81 | * @param bool $login true if getting user as part of login process 82 | * @return User with specified entity uid 83 | * @throws UserNotFoundException if no user with that uid exists 84 | */ 85 | public function get_user_by_uid($uid, $login = false) { 86 | if(isset($this->cache_uid[$uid])) { 87 | return $this->cache_uid[$uid]; 88 | } 89 | $stmt = $this->database->prepare("SELECT * FROM user WHERE uid = ?"); 90 | $stmt->bind_param('s', $uid); 91 | $stmt->execute(); 92 | $result = $stmt->get_result(); 93 | if($row = $result->fetch_assoc()) { 94 | $user = new User($row['entity_id'], $row); 95 | $this->cache_uid[$uid] = $user; 96 | } else { 97 | $user = new User; 98 | $user->uid = $uid; 99 | $this->cache_uid[$uid] = $user; 100 | $user->get_details_from_ldap($login); 101 | } 102 | $stmt->close(); 103 | return $user; 104 | } 105 | 106 | /** 107 | * List all users in the database. 108 | * @param array $include list of extra data to include in response - currently unused 109 | * @param array $filter list of field/value pairs to filter results on 110 | * @return array of User objects 111 | */ 112 | public function list_users($include = array(), $filter = array()) { 113 | // WARNING: The search query is not parameterized - be sure to properly escape all input 114 | $fields = array("user.*"); 115 | $joins = array(); 116 | $where = array(); 117 | foreach($filter as $field => $value) { 118 | if($value) { 119 | switch($field) { 120 | case 'uid': 121 | $where[] = "uid = '".$this->database->escape_string($value)."'"; 122 | break; 123 | case 'name': 124 | $where[] = "name = '".$this->database->escape_string($value)."'"; 125 | break; 126 | case 'admins_servers': 127 | $joins[] = "INNER JOIN server_admin ON server_admin.entity_id = user.entity_id"; 128 | $joins[] = "INNER JOIN server ON server.id = server_admin.server_id AND server.key_management <> 'decommissioned'"; 129 | break; 130 | } 131 | } 132 | } 133 | $stmt = $this->database->prepare(" 134 | SELECT ".implode(", ", $fields)." 135 | FROM user ".implode(" ", $joins)." 136 | ".(count($where) == 0 ? "" : "WHERE (".implode(") AND (", $where).")")." 137 | GROUP BY user.entity_id 138 | ORDER BY user.uid 139 | "); 140 | $stmt->execute(); 141 | $result = $stmt->get_result(); 142 | $users = array(); 143 | while($row = $result->fetch_assoc()) { 144 | $users[] = new User($row['entity_id'], $row); 145 | } 146 | return $users; 147 | } 148 | } 149 | 150 | class UserNotFoundException extends Exception {} 151 | -------------------------------------------------------------------------------- /model/userevent.php: -------------------------------------------------------------------------------- 1 | data['entity_id']); 31 | return $user; 32 | default: 33 | return parent::__get($field); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pagesection.php: -------------------------------------------------------------------------------- 1 | template = $template; 28 | $this->data = new StdClass; 29 | $this->data->menu_items = array(); 30 | $this->data->menu_items['/'] = 'Home'; 31 | $this->data->menu_items['/servers'] = 'Servers'; 32 | $this->data->menu_items['/users'] = 'Users'; 33 | $this->data->menu_items['/groups'] = 'Groups'; 34 | $this->data->menu_items['/pubkeys'] = 'Public keys'; 35 | if($active_user && ($active_user->admin || count($active_user->list_admined_servers()) > 0)) { 36 | $this->data->menu_items['/activity'] = 'Activity'; 37 | } 38 | if($active_user && $active_user->admin) { 39 | $this->data->menu_items['/tools'] = 'Tools'; 40 | } 41 | $this->data->menu_items['/help'] = 'Help'; 42 | $this->data->relative_request_url = $relative_request_url; 43 | $this->data->active_user = $active_user; 44 | $this->data->web_config = $config['web']; 45 | $this->data->email_config = $config['email']; 46 | if($active_user && $active_user->developer) { 47 | $this->data->database = $database; 48 | } 49 | } 50 | public function set_by_array($array, $prefix = '') { 51 | foreach($array as $item => $data) { 52 | $this->setData($prefix.$item, $data); 53 | } 54 | } 55 | public function set($item, $data) { 56 | $this->data->$item = $data; 57 | } 58 | public function get($item) { 59 | if(isset($this->data->$item)) { 60 | if(is_object($this->data->$item) && get_class($this->data->$item) == 'PageSection') { 61 | return $this->data->$item->generate(); 62 | } else { 63 | return $this->data->$item; 64 | } 65 | } else { 66 | return null; 67 | } 68 | } 69 | public function generate() { 70 | ob_start(); 71 | $data = $this->data; 72 | include_once(path_join('templates', 'functions.php')); 73 | include(path_join('templates', $this->template.'.php')); 74 | $output = ob_get_contents(); 75 | ob_end_clean(); 76 | return $output; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /public_html/bootstrap/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/operasoftware/ssh-key-authority/d8263bf7a9ebb49cd232f63cdff31cd8a4f91b09/public_html/bootstrap/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /public_html/bootstrap/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/operasoftware/ssh-key-authority/d8263bf7a9ebb49cd232f63cdff31cd8a4f91b09/public_html/bootstrap/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /public_html/bootstrap/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/operasoftware/ssh-key-authority/d8263bf7a9ebb49cd232f63cdff31cd8a4f91b09/public_html/bootstrap/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /public_html/bootstrap/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/operasoftware/ssh-key-authority/d8263bf7a9ebb49cd232f63cdff31cd8a4f91b09/public_html/bootstrap/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /public_html/bootstrap/js/npm.js: -------------------------------------------------------------------------------- 1 | // This file is autogenerated via the `commonjs` Grunt task. You can require() this file in a CommonJS environment. 2 | require('../../js/transition.js') 3 | require('../../js/alert.js') 4 | require('../../js/button.js') 5 | require('../../js/carousel.js') 6 | require('../../js/collapse.js') 7 | require('../../js/dropdown.js') 8 | require('../../js/modal.js') 9 | require('../../js/tooltip.js') 10 | require('../../js/popover.js') 11 | require('../../js/scrollspy.js') 12 | require('../../js/tab.js') 13 | require('../../js/affix.js') -------------------------------------------------------------------------------- /public_html/header.js: -------------------------------------------------------------------------------- 1 | /* 2 | ## 3 | ## Copyright 2013-2017 Opera Software AS 4 | ## 5 | ## Licensed under the Apache License, Version 2.0 (the "License"); 6 | ## you may not use this file except in compliance with the License. 7 | ## You may obtain a copy of the License at 8 | ## 9 | ## http://www.apache.org/licenses/LICENSE-2.0 10 | ## 11 | ## Unless required by applicable law or agreed to in writing, software 12 | ## distributed under the License is distributed on an "AS IS" BASIS, 13 | ## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | ## See the License for the specific language governing permissions and 15 | ## limitations under the License. 16 | ## 17 | */ 18 | 'use strict'; 19 | 20 | // Lightweight things to do before the page is displayed 21 | // This should not rely on any JQuery or other libraries 22 | 23 | 24 | // Hide the key fingerprints that we are not interested in 25 | var sheet = document.styleSheets[0]; 26 | var fingerprint_hash; 27 | if(localStorage && localStorage.getItem('preferred_fingerprint_hash') == 'SHA256') { 28 | sheet.insertRule('span.fingerprint_md5 {display:none}', 0) 29 | } else { 30 | sheet.insertRule('span.fingerprint_sha256 {display:none}', 0) 31 | } -------------------------------------------------------------------------------- /public_html/init.php: -------------------------------------------------------------------------------- 1 | .container { 34 | padding: 60px 15px 0; 35 | } 36 | #content *:first-child { 37 | margin-top: 0; 38 | } 39 | .nav-tabs { 40 | margin-bottom: 20px; 41 | } 42 | #footer { 43 | height: 60px; 44 | background-color: #f5f5f5; 45 | } 46 | #footer > .container { 47 | padding: 20px 15px 0 15px; 48 | } 49 | .navbar-brand img { 50 | height: 100%; 51 | float: left; 52 | margin-right: 1em; 53 | } 54 | .panel-group + p { 55 | margin-top: 1em; 56 | } 57 | a.group, a.server, a.serveraccount, a.user { 58 | white-space: nowrap; 59 | } 60 | a.group::before { 61 | content: "\e032"; 62 | /*content: "\e056";*/ 63 | display: inline-block; 64 | font-family: "Glyphicons Halflings"; 65 | font-style: normal; 66 | font-weight: 400; 67 | line-height: 1; 68 | position: relative; 69 | top: 1px; 70 | padding-right: 0.4em; 71 | } 72 | a.server::before { 73 | content: "\e121"; 74 | display: inline-block; 75 | font-family: "Glyphicons Halflings"; 76 | font-style: normal; 77 | font-weight: 400; 78 | line-height: 1; 79 | position: relative; 80 | top: 1px; 81 | padding-right: 0.4em; 82 | } 83 | a.serveraccount::before { 84 | content: "\e161"; 85 | display: inline-block; 86 | font-family: "Glyphicons Halflings"; 87 | font-style: normal; 88 | font-weight: 400; 89 | line-height: 1; 90 | position: relative; 91 | top: 1px; 92 | padding-right: 0.4em; 93 | } 94 | a.user::before { 95 | content: "\e008"; 96 | display: inline-block; 97 | font-family: "Glyphicons Halflings"; 98 | font-style: normal; 99 | font-weight: 400; 100 | line-height: 1; 101 | position: relative; 102 | top: 1px; 103 | padding-right: 0.4em; 104 | } 105 | .input-group-addon label { 106 | margin: 0; 107 | } 108 | .indented { 109 | padding-left: 2em !important; 110 | } 111 | dl.oneline dt::before { 112 | content: '\A'; 113 | white-space: pre; 114 | } 115 | dl.oneline dt:first-child::before { 116 | white-space: normal; 117 | } 118 | dl.oneline dt { 119 | display: inline; 120 | } 121 | dl.oneline dd { 122 | display: inline; 123 | padding-left: 0.5em; 124 | } 125 | dl.spaced dd { 126 | margin-bottom: 1em; 127 | } 128 | ul.compact { 129 | margin: 0; 130 | padding: 0; 131 | list-style-type: none; 132 | } 133 | pre { 134 | white-space: pre-wrap; 135 | } 136 | pre.ascii-art { 137 | line-height: 1; 138 | } 139 | .pre-formatted { 140 | word-break: break-all; 141 | word-wrap: break-word; 142 | white-space: pre-wrap; 143 | } 144 | span.date { 145 | white-space: nowrap; 146 | } 147 | .nowrap { 148 | white-space: nowrap; 149 | } 150 | .spinner { 151 | display: inline-block; 152 | width: 12px; 153 | height: 12px; 154 | -webkit-animation: spinner 1s infinite linear; 155 | animation: spinner 1s infinite linear; 156 | border-radius:7px; 157 | border-left:2px solid gray; 158 | border-bottom:2px solid gray; 159 | } 160 | @-webkit-keyframes spinner { 161 | to { 162 | -webkit-transform: rotate(360deg); 163 | } 164 | } 165 | @keyframes spinner { 166 | to { 167 | transform: rotate(360deg); 168 | } 169 | } 170 | .monospace { 171 | font-family: monospace; 172 | } 173 | td.date { 174 | width: 11em; 175 | } 176 | 177 | /* Now with 100% more pink! */ 178 | div.navbar-default { 179 | background-color: #fff1f9; 180 | border-color: #f7e7f6; 181 | } 182 | .navbar-default .navbar-nav>.active>a, .navbar-default .navbar-nav>.active>a:focus, .navbar-default .navbar-nav>.active>a:hover { 183 | background-color: #ffdfef; 184 | } 185 | a { 186 | color: #af3578; 187 | } 188 | a:focus, a:hover { 189 | color: #611d42; 190 | } 191 | -------------------------------------------------------------------------------- /requesthandler.php: -------------------------------------------------------------------------------- 1 | get_user_by_uid($_SERVER['PHP_AUTH_USER'], true); 25 | } else { 26 | throw new Exception("Not logged in."); 27 | } 28 | 29 | // Work out where we are on the server 30 | $base_url = dirname($_SERVER['SCRIPT_NAME']); 31 | $request_url = $_SERVER['REQUEST_URI']; 32 | $relative_request_url = preg_replace('/^'.preg_quote($base_url, '/').'/', '/', $request_url); 33 | $absolute_request_url = 'http'.(isset($_SERVER['HTTPS']) ? 's' : '').'://'.$_SERVER['HTTP_HOST'].$request_url; 34 | 35 | if(empty($config['web']['enabled'])) { 36 | require('views/error503.php'); 37 | die; 38 | } 39 | 40 | if(!$active_user->active) { 41 | require('views/error403.php'); 42 | } 43 | 44 | if(!empty($_POST)) { 45 | // Check CSRF token 46 | if(isset($_SERVER['HTTP_X_BYPASS_CSRF_PROTECTION']) && $_SERVER['HTTP_X_BYPASS_CSRF_PROTECTION'] == 1) { 47 | // This is being called from script, not a web browser 48 | } elseif(!$active_user->check_csrf_token($_POST['csrf_token'])) { 49 | require('views/csrf.php'); 50 | die; 51 | } 52 | } 53 | 54 | // Route request to the correct view 55 | $router = new Router; 56 | foreach($routes as $path => $service) { 57 | $public = array_key_exists($path, $public_routes); 58 | $router->add_route($path, $service, $public); 59 | } 60 | $router->handle_request($relative_request_url); 61 | if(isset($router->view)) { 62 | $view = path_join($base_path, 'views', $router->view.'.php'); 63 | if(file_exists($view)) { 64 | if($active_user->auth_realm == 'LDAP' || $router->public) { 65 | require($view); 66 | } else { 67 | require('views/error403.php'); 68 | } 69 | } else { 70 | throw new Exception("View file $view missing."); 71 | } 72 | } 73 | 74 | // Handler for uncaught exceptions 75 | function exception_handler($e) { 76 | global $active_user, $config; 77 | $error_number = time(); 78 | error_log("$error_number: ".str_replace("\n", "\n$error_number: ", $e)); 79 | while(ob_get_length()) { 80 | ob_end_clean(); 81 | } 82 | require('views/error500.php'); 83 | die; 84 | } 85 | -------------------------------------------------------------------------------- /router.php: -------------------------------------------------------------------------------- 1 | route_vars = array(); 27 | $path = preg_replace_callback('|\\\{([a-z]+)\\\}|', array($this, 'parse_route_variable'), preg_quote($path, '|')); 28 | $route = new StdClass; 29 | $route->view = $view; 30 | $route->vars = $this->route_vars; 31 | $route->public = $public; 32 | $this->routes[$path] = $route; 33 | } 34 | 35 | private function parse_route_variable($matches) { 36 | $this->route_vars[] = $matches[1]; 37 | return '([^/]*)'; 38 | } 39 | 40 | public function handle_request($request_path) { 41 | $request_path = preg_replace('|\?.*$|', '', $request_path); 42 | foreach($this->routes as $path => $route) { 43 | if(preg_match('|^'.$path.'$|', $request_path, $matches)) { 44 | $this->view = $route->view; 45 | $this->public = $route->public; 46 | $i = 0; 47 | foreach($route->vars as $var) { 48 | $i++; 49 | if(isset($matches[$i])) { 50 | $this->vars[$var] = urldecode($matches[$i]); 51 | } 52 | } 53 | } 54 | } 55 | if(is_null($this->view)) { 56 | $this->view = 'error404'; 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /routes.php: -------------------------------------------------------------------------------- 1 | 'home', 20 | '/activity' => 'activity', 21 | '/bulk_mail' => 'bulk_mail', 22 | '/bulk_mail/{recipients}' => 'bulk_mail', 23 | '/groups' => 'groups', 24 | '/groups/{group}' => 'group', 25 | '/groups/{group}/members.{format}' => 'group', 26 | '/groups/{group}/access_rules/{access}' => 'access_options', 27 | '/help' => 'help', 28 | '/pubkeys' => 'pubkeys', 29 | '/pubkeys.{format}' => 'pubkeys', 30 | '/pubkeys/{key}' => 'pubkey', 31 | '/pubkeys/{key}.{format}' => 'pubkey', 32 | '/servers' => 'servers', 33 | '/servers.{format}' => 'servers', 34 | '/servers/{hostname}' => 'server', 35 | '/servers/{hostname}/accounts/{account}' => 'serveraccount', 36 | '/servers/{hostname}/accounts/{account}/access_rules/{access}' => 'access_options', 37 | '/servers/{hostname}/accounts/{account}/pubkeys.{format}' => 'serveraccount_pubkeys', 38 | '/servers/{hostname}/accounts/{account}/sync_status' => 'serveraccount_sync_status', 39 | '/servers/{hostname}/status.{format}' => 'server', 40 | '/servers/{hostname}/sync_status' => 'server_sync_status', 41 | '/tools' => 'tools', 42 | '/users' => 'users', 43 | '/users/{username}' => 'user', 44 | '/users/{username}/pubkeys' => 'user_pubkeys', 45 | '/users/{username}/pubkeys.{format}' => 'user_pubkeys', 46 | '/users/{username}/pubkeys/{key}' => 'pubkey', 47 | '/users/{username}/pubkeys/{key}.{format}' => 'pubkey', 48 | ); 49 | 50 | $public_routes = array( 51 | '/groups/{group}/members.{format}' => true, 52 | '/pubkeys.{format}' => true, 53 | '/pubkeys/{key}.{format}' => true, 54 | '/servers/{hostname}/accounts/{account}/pubkeys.{format}' => true, 55 | '/users/{username}' => true, 56 | '/users/{username}/pubkeys.{format}' => true, 57 | '/users/{username}/pubkeys/{key}.{format}' => true, 58 | ); 59 | -------------------------------------------------------------------------------- /scripts/ldap_update.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | list_users(); 23 | 24 | // Use 'keys-sync' user as the active user (create if it does not yet exist) 25 | try { 26 | $active_user = $user_dir->get_user_by_uid('keys-sync'); 27 | } catch(UserNotFoundException $e) { 28 | $active_user = new User; 29 | $active_user->uid = 'keys-sync'; 30 | $active_user->name = 'Synchronization script'; 31 | $active_user->email = ''; 32 | $active_user->active = 1; 33 | $active_user->admin = 1; 34 | $active_user->developer = 0; 35 | $user_dir->add_user($active_user); 36 | } 37 | 38 | foreach($users as $user) { 39 | if($user->auth_realm == 'LDAP') { 40 | $active = $user->active; 41 | try { 42 | $user->get_details_from_ldap(); 43 | if(isset($config['ldap']['user_superior'])) { 44 | $user->get_superior_from_ldap(); 45 | } 46 | } catch(UserNotFoundException $e) { 47 | $user->active = 0; 48 | } 49 | if($active && !$user->active) { 50 | // Check for servers that will now be admin-less 51 | $servers = $user->list_admined_servers(); 52 | foreach($servers as $server) { 53 | $server_admins = $server->list_effective_admins(); 54 | $total_server_admins = 0; 55 | foreach($server_admins as $server_admin) { 56 | if($server_admin->active) $total_server_admins++; 57 | } 58 | if($total_server_admins == 0) { 59 | if(isset($config['ldap']['user_superior'])) { 60 | $rcpt = $user->superior; 61 | while(!is_null($rcpt) && !$rcpt->active) { 62 | $rcpt = $rcpt->superior; 63 | } 64 | } 65 | $email = new Email; 66 | $email->subject = "Server {$server->hostname} has been orphaned"; 67 | $email->body = "{$user->name} ({$user->uid}) was an administrator for {$server->hostname}, but they have now been marked as a former employee and there are no active administrators remaining for this server.\n\n"; 68 | $email->body .= "Please find a replacement owner for this server and inform {$config['email']['admin_address']} ASAP, otherwise the server will be registered for decommissioning."; 69 | $email->add_reply_to($config['email']['admin_address'], $config['email']['admin_name']); 70 | if(is_null($rcpt)) { 71 | $email->subject .= " - NO SUPERIOR EMPLOYEE FOUND"; 72 | $email->body .= "\n\nWARNING: No suitable superior employee could be found!"; 73 | $email->add_recipient($config['email']['report_address'], $config['email']['report_name']); 74 | } else { 75 | $email->add_recipient($rcpt->email, $rcpt->name); 76 | $email->add_cc($config['email']['report_address'], $config['email']['report_name']); 77 | } 78 | $email->send(); 79 | } 80 | } 81 | } 82 | $user->update(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /scripts/pubkey_update.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | list_public_keys(); 23 | foreach($pubkeys as $pubkey) { 24 | try { 25 | $pubkey->import($pubkey->export(), null, true); 26 | $pubkey->update(); 27 | } catch(InvalidArgumentException $e) { 28 | echo "Invalid public key {$pubkey->id}\n"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /scripts/sync-common.php: -------------------------------------------------------------------------------- 1 | request = $request; 39 | $this->output = ''; 40 | $descriptorspec = array( 41 | 0 => array("pipe", "r"), // stdin 42 | 1 => array("pipe", "w"), // stdout 43 | 2 => array("pipe", "w"), // stderr 44 | 3 => array("pipe", "w") // 45 | ); 46 | switch ($timeout_util) { 47 | case "BusyBox": 48 | $commandline = '/usr/bin/timeout -t 60 '.$command.' '.implode(' ', array_map('escapeshellarg', $args)); 49 | break; 50 | default: 51 | $commandline = '/usr/bin/timeout 60s '.$command.' '.implode(' ', array_map('escapeshellarg', $args)); 52 | } 53 | 54 | $this->handle = proc_open($commandline, $descriptorspec, $this->pipes); 55 | stream_set_blocking($this->pipes[1], 0); 56 | stream_set_blocking($this->pipes[2], 0); 57 | } 58 | 59 | /** 60 | * Get data from the child process 61 | * @return string output from the child process 62 | */ 63 | public function get_data() { 64 | if(isset($this->handle) && is_resource($this->handle)) { 65 | $out = fread($this->pipes[1], 4096); 66 | $this->output .= $out; 67 | $this->errors .= fread($this->pipes[2], 4096); 68 | if(feof($this->pipes[1]) && feof($this->pipes[2])) { 69 | foreach($this->pipes as $ref => $pipe) { 70 | fclose($this->pipes[$ref]); 71 | } 72 | unset($this->handle); 73 | if($this->errors) { 74 | echo $this->errors; 75 | $this->output = ''; 76 | } 77 | return array('done' => true, 'output' => $this->output); 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * Delete the request that triggered this sync 84 | */ 85 | public function __destruct() { 86 | global $sync_request_dir; 87 | if(!is_null($this->request)) { 88 | $sync_request_dir->delete_sync_request($this->request); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /scripts/syncd.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php 2 | list_pending_sync_requests(); 127 | foreach($reqs as $req) { 128 | $args = array(); 129 | $args[] = '--id'; 130 | $args[] = $req->server_id; 131 | if(!is_null($req->account_name)) { 132 | $args[] = '--user'; 133 | $args[] = $req->account_name; 134 | } 135 | if(count($sync_procs) > MAX_PROCS) break; 136 | $req->set_in_progress(); 137 | dlog("Sync process spawning for: {$req->server_id}/{$req->account_name}"); 138 | $sync_procs[] = new SyncProcess(__DIR__.'/sync.php', $args, $req); 139 | } 140 | } catch(mysqli_sql_exception $e) { 141 | if($e->getMessage() == 'MySQL server has gone away') { 142 | dlog("MySQL server has gone away"); 143 | $connected = false; 144 | while(!$connected) { 145 | try { 146 | setup_database(); 147 | $connected = true; 148 | dlog("MySQL connection re-established"); 149 | } catch(mysqli_sql_exception $e2) { 150 | dlog("Attempt to reconnect failed: ".$e2->getMessage()); 151 | sleep(5); 152 | } 153 | } 154 | } 155 | } 156 | foreach($sync_procs as $ref => &$sync_proc) { 157 | $data = $sync_proc->get_data(); 158 | if(!empty($data)) { 159 | dlog($data['output']); 160 | unset($sync_proc); 161 | unset($sync_procs[$ref]); 162 | } 163 | } 164 | sleep(1); 165 | } 166 | dlog("Received exit signal"); 167 | 168 | if(!isset($options['systemd'])) { 169 | // Release lock 170 | flock($lock, LOCK_UN); 171 | fclose($lock); 172 | } 173 | -------------------------------------------------------------------------------- /services/README: -------------------------------------------------------------------------------- 1 | To install the sync service 2 | =========================== 3 | 4 | On a systemd system: 5 | 1) Copy the systemd/keys-sync.service file to /etc/systemd/system/ 6 | 2) Modify ExecStart path and User as necessary. If SSH Key Authority is installed under /home, disable ProtectHome. 7 | 3) Run: systemctl daemon-reload 8 | 4) Run: systemctl enable keys-sync.service 9 | 10 | On a sysvinit system: 11 | 1) Copy the init.d/keys-sync file to /etc/init.d/ 12 | 2) Modify SCRIPT path and USER as necessary. 13 | 3) Run: update-rc.d keys-sync defaults 14 | -------------------------------------------------------------------------------- /services/init.d/keys-sync: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ### BEGIN INIT INFO 4 | # Provides: keys-sync 5 | # Required-Start: mysql 6 | # Required-Stop: mysql 7 | # Default-Start: 2 3 4 5 8 | # Default-Stop: 0 1 6 9 | # Short-Description: SSH key synchronization daemon 10 | ### END INIT INFO 11 | 12 | . /lib/lsb/init-functions 13 | 14 | SCRIPT=/srv/keys/scripts/syncd.php 15 | USER=keys-sync 16 | PIDFILE=/var/run/keys-sync.pid 17 | 18 | test -f $SCRIPT || exit 0 19 | 20 | case "$1" in 21 | start) 22 | log_daemon_msg "Starting keys-sync daemon" 23 | start-stop-daemon --start --quiet --pidfile $PIDFILE --startas $SCRIPT --user $USER -- 24 | log_end_msg $? 25 | ;; 26 | stop) 27 | log_daemon_msg "Stopping keys-sync daemon" 28 | start-stop-daemon --stop --quiet --pidfile $PIDFILE --name syncd.php --user $USER 29 | log_end_msg $? 30 | rm -f $PIDFILE 31 | ;; 32 | restart) 33 | $0 stop && $0 start 34 | ;; 35 | *) 36 | log_action_msg "Usage: /etc/init.d/keys-sync {start|stop|restart}" 37 | exit 2 38 | ;; 39 | esac 40 | exit 0 41 | -------------------------------------------------------------------------------- /services/systemd/keys-sync.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SSH Key synchronization daemon 3 | Documentation=https://github.com/operasoftware/ssh-key-authority 4 | Requires=mysql.service 5 | 6 | [Service] 7 | Type=simple 8 | ExecStart=/srv/keys/scripts/syncd.php --systemd 9 | User=keys-sync 10 | StandardOutput=journal 11 | StandardError=journal 12 | PrivateDevices=on 13 | PrivateTmp=on 14 | ProtectHome=on 15 | ProtectSystem=on 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /templates/access_options.php: -------------------------------------------------------------------------------- 1 | get('entity'); 18 | switch(get_class($entity)) { 19 | case 'ServerAccount': $account = $entity; $server = $entity->server; break; 20 | case 'Group': $group = $entity; break; 21 | } 22 | $remote_entity = $this->get('remote_entity'); 23 | $mode = $this->get('mode'); 24 | $options = $this->get('options'); 25 | switch(get_class($remote_entity)) { 26 | case 'User': $remote_entity_name = $remote_entity->uid; break; 27 | case 'ServerAccount': $remote_entity_name = $remote_entity->name.'@'.$remote_entity->server->hostname; break; 28 | case 'Group': $remote_entity_name = $remote_entity->name; break; 29 | } 30 | ?> 31 |
Entity | 27 |User | 28 |Activity | 29 |Date (UTC) | 30 |
---|
Choose recipients:
20 |Your request was missing the required security token. Please try submitting your request again.
20 | -------------------------------------------------------------------------------- /templates/entity_pubkeys_json.php: -------------------------------------------------------------------------------- 1 | get('pubkeys') as $pubkey) { 19 | $json[] = pubkey_json($pubkey, true, false); 20 | } 21 | out(json_encode($json), ESC_NONE); 22 | -------------------------------------------------------------------------------- /templates/entity_pubkeys_txt.php: -------------------------------------------------------------------------------- 1 | get('pubkeys') as $pubkey) { 18 | out($pubkey->export()."\n", ESC_NONE); 19 | } 20 | -------------------------------------------------------------------------------- /templates/error403.php: -------------------------------------------------------------------------------- 1 | 18 |Sorry, but you don't have permission to view this page.
20 | -------------------------------------------------------------------------------- /templates/error404.php: -------------------------------------------------------------------------------- 1 | 18 |Sorry, but the address you've given doesn't seem to point to a valid page.
20 |If you got here by following a link, please report it to us. Otherwise, please make sure that you have typed the address correctly, or just start browsing from the keys home page.
21 | -------------------------------------------------------------------------------- /templates/error500.php: -------------------------------------------------------------------------------- 1 | 18 | get('error_details')) { ?> 19 |get('exception_class')) ?> "get('error_details')->getMessage()) ?>"
21 |Occurred in get('error_details')->getFile().' line '.$this->get('error_details')->getLine()) ?>
22 |Function | 27 |Arguments | 28 |Location | 29 |
---|---|---|
36 | |
37 |
38 |
|
45 | 46 | |
Sorry, but it looks like something needs fixing on the system. The problem has been automatically reported to the administrators, but if you wish, you can also provide additional information about what you were doing that may have triggered the error.
54 | 55 | -------------------------------------------------------------------------------- /templates/error503.php: -------------------------------------------------------------------------------- 1 | 18 |Sorry for the inconvenience. We should be back soon though, so press the reload button in your browser in a few minutes to try again.
20 | -------------------------------------------------------------------------------- /templates/group_json.php: -------------------------------------------------------------------------------- 1 | users = array(); 19 | $json->server_accounts = array(); 20 | foreach($this->get('group_members') as $member) { 21 | $group_member = new StdClass; 22 | if(get_class($member) == 'User') { 23 | $group_member->uid = $member->uid; 24 | $group_member->email = $member->email; 25 | $json->users[] = $group_member; 26 | } elseif(get_class($member) == 'ServerAccount') { 27 | $group_member->name = $member->name; 28 | $group_member->hostname = $member->server->hostname; 29 | $json->server_accounts[] = $group_member; 30 | } 31 | } 32 | out(json_encode($json), ESC_NONE); 33 | -------------------------------------------------------------------------------- /templates/group_not_found.php: -------------------------------------------------------------------------------- 1 | 18 |The group name you entered isn't yet known by the keys management server. Please go back and try again.
21 |No groups found.
66 | 67 | 96 | 97 |"get('project_name'))?>" doesn't look like a valid project name. Forward slashes (/) are not allowed in the project name. Please go back and try again.
21 |"get('hostname'))?>" doesn't look like a valid hostname. Please go back and try again.
21 |get('message')) ?> Please go back and try again.
21 |Your request cannot be fulfilled because you are not an administrator of the target entity.
20 | -------------------------------------------------------------------------------- /templates/pubkey.php: -------------------------------------------------------------------------------- 1 | get('pubkey')->owner; 18 | ?> 19 |get('pubkey')->export())?>
get('pubkey')->randomart_md5)?>
get('pubkey')->randomart_sha256)?>
This key will currently be synced to all accounts and servers that is granted access to. To restrict this key to a subset of that list, add rules below.
108 | 109 |This key will only be synced to accounts and servers that is granted access to that also match the following rules:
110 | 131 | 132 | 148 |get('pubkeys')); out(number_format($total).' public key'.($total == 1 ? '' : 's').' found')?>
58 |Fingerprint | 62 |Type | 63 |Size | 64 |Comment | 65 |Owner | 66 |
---|---|---|---|---|
74 | 75 | fingerprint_md5)?> 76 | fingerprint_sha256)?> 77 | 78 | | 79 |type)?> | 80 |keysize < 4095) out(' class="danger"', ESC_NONE)?>>keysize)?> | 81 |comment)?> | 82 |83 | owner)) { 85 | case 'User': 86 | ?> 87 | owner->uid)?> 88 | owner->active) out(' Inactive', ESC_NONE) ?> 89 | 93 | owner->name.'@'.$pubkey->owner->server->hostname)?> 94 | owner->server->key_management == 'decommissioned') out(' Inactive', ESC_NONE) ?> 95 | 99 | | 100 |
The server account you entered isn't yet known by the keys management server. You'll need to add it first. Please go back and try again.
21 |The hostname you entered isn't yet known by the keys management server. You'll need to add it first. Please go back and try again.
21 |get('message')) ?> Please go back and try again.
21 |The fields)))?> "values))?>" already exists. Please go back and try again.
24 | 25 |The values you provided are in conflict with existing records. Please go back and try again.
26 | 27 |The user ID you entered doesn't appear to be a valid active LDAP user account. Please go back and try again.
21 |export())?>
Username | 23 |Full name | 24 |Public keys | 25 |
---|---|---|
uid)?> | 31 |name)?> | 32 |list_public_keys())))?> | 33 |