Passed
Push — master ( b32147...6275ed )
by Maja
06:20
created

DeploymentManaged::sendToRADIUS()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 21
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 17
dl 0
loc 21
rs 9.7
c 0
b 0
f 0
cc 4
nc 3
nop 2
1
<?php
2
3
/*
4
 * *****************************************************************************
5
 * Contributions to this work were made on behalf of the GÉANT project, a 
6
 * project that has received funding from the European Union’s Framework 
7
 * Programme 7 under Grant Agreements No. 238875 (GN3) and No. 605243 (GN3plus),
8
 * Horizon 2020 research and innovation programme under Grant Agreements No. 
9
 * 691567 (GN4-1) and No. 731122 (GN4-2).
10
 * On behalf of the aforementioned projects, GEANT Association is the sole owner
11
 * of the copyright in all material which was developed by a member of the GÉANT
12
 * project. GÉANT Vereniging (Association) is registered with the Chamber of 
13
 * Commerce in Amsterdam with registration number 40535155 and operates in the 
14
 * UK as a branch of GÉANT Vereniging.
15
 * 
16
 * Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. 
17
 * UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK
18
 *
19
 * License: see the web/copyright.inc.php file in the file structure or
20
 *          <base_url>/copyright.php after deploying the software
21
 */
22
23
/**
24
 * This file contains the AbstractProfile class. It contains common methods for
25
 * both RADIUS/EAP profiles and SilverBullet profiles
26
 *
27
 * @author Stefan Winter <[email protected]>
28
 * @author Tomasz Wolniewicz <[email protected]>
29
 *
30
 * @package Developer
31
 *
32
 */
33
34
namespace core;
35
36
use \Exception;
37
38
/**
39
 * This class represents an EAP Profile.
40
 * Profiles can inherit attributes from their IdP, if the IdP has some. Otherwise,
41
 * one can set attribute in the Profile directly. If there is a conflict between
42
 * IdP-wide and Profile-wide attributes, the more specific ones (i.e. Profile) win.
43
 * 
44
 * @author Stefan Winter <[email protected]>
45
 * @author Tomasz Wolniewicz <[email protected]>
46
 *
47
 * @license see LICENSE file in root directory
48
 *
49
 * @package Developer
50
 */
51
class DeploymentManaged extends AbstractDeployment {
52
53
    /**
54
     * This is the limit for dual-stack hosts. Single stack uses half of the FDs
55
     * in FreeRADIUS and take twice as many. initialise() takes this into
56
     * account.
57
     */
58
    const MAX_CLIENTS_PER_SERVER = 200;
59
    const PRODUCTNAME = "Managed SP";
60
61
    /**
62
     * the primary RADIUS server port for this SP instance
63
     * 
64
     * @var integer
65
     */
66
    public $port1;
67
68
    /**
69
     * the backup RADIUS server port for this SP instance
70
     * 
71
     * @var integer
72
     */
73
    public $port2;
74
75
    /**
76
     * the shared secret for this SP instance
77
     * 
78
     * @var string
79
     */
80
    public $secret;
81
82
    /**
83
     * the IPv4 address of the primary RADIUS server for this SP instance 
84
     * (can be NULL)
85
     * 
86
     * @var string
87
     */
88
    public $host1_v4;
89
90
    /**
91
     * the IPv6 address of the primary RADIUS server for this SP instance 
92
     * (can be NULL)
93
     * 
94
     * @var string
95
     */
96
    public $host1_v6;
97
98
    /**
99
     * the IPv4 address of the backup RADIUS server for this SP instance 
100
     * (can be NULL)
101
     * 
102
     * @var string
103
     */
104
    public $host2_v4;
105
106
    /**
107
     * the IPv6 address of the backup RADIUS server for this SP instance 
108
     * (can be NULL)
109
     * 
110
     * @var string
111
     */
112
    public $host2_v6;
113
114
    /**
115
     * the primary RADIUS server instance for this SP instance
116
     * 
117
     * @var string
118
     */
119
    public $radius_instance_1;
120
121
    /**
122
     * the backup RADIUS server instance for this SP instance
123
     * 
124
     * @var string
125
     */
126
    public $radius_instance_2;
127
    
128
    /**
129
     * the primary RADIUS server hostname - for sending configuration requests
130
     * 
131
     * @var string
132
     */
133
    public $radius_hostname_1;
134
135
    /**
136
     * the backup RADIUS server hostname - for sending configuration requests
137
     * 
138
     * @var string
139
     */
140
    public $radius_hostname_2;
141
142
    /**
143
     * the primary RADIUS server status - last configuration request result
144
     * 
145
     * @var string
146
     */
147
    public $radius_status_1;
148
149
    /**
150
     * the backup RADIUS server status - last configuration request result
151
     * 
152
     * @var string
153
     */
154
    public $radius_status_2;
155
    /**
156
     * Class constructor for existing deployments (use 
157
     * IdP::newDeployment() to actually create one). Retrieves all 
158
     * attributes from the DB and stores them in the priv_ arrays.
159
     * 
160
     * @param IdP        $idpObject       optionally, the institution to which this Profile belongs. Saves the construction of the IdP instance. If omitted, an extra query and instantiation is executed to find out.
161
     * @param string|int $deploymentIdRaw identifier of the deployment in the DB
162
     * @throws Exception
163
     */
164
    public function __construct($idpObject, $deploymentIdRaw) {
165
        parent::__construct($idpObject, $deploymentIdRaw); // we now have access to our INST database handle and logging
166
        $this->entityOptionTable = "deployment_option";
167
        $this->entityIdColumn = "deployment_id";
168
        $this->type = AbstractDeployment::DEPLOYMENTTYPE_MANAGED;
169
        if (!is_numeric($deploymentIdRaw)) {
170
            throw new Exception("Managed SP instances have to have a numeric identifier");
171
        }
172
        $propertyQuery = "SELECT status,port_instance_1,port_instance_2,secret,radius_instance_1,radius_instance_2,radius_status_1,radius_status_2 FROM deployment WHERE deployment_id = ?";
173
        $queryExec = $this->databaseHandle->exec($propertyQuery, "i", $deploymentIdRaw);
174
        if (mysqli_num_rows(/** @scrutinizer ignore-type */ $queryExec) == 0) {
175
            throw new Exception("Attempt to construct an unknown DeploymentManaged!");
176
        }
177
        $this->identifier = $deploymentIdRaw;
178
        while ($iterator = mysqli_fetch_object(/** @scrutinizer ignore-type */ $queryExec)) {
179
            if ($iterator->secret == NULL && $iterator->radius_instance_1 == NULL) {
180
                // we are instantiated for the first time, initialise us
181
                $details = $this->initialise();
182
                $this->port1 = $details["port_instance_1"];
183
                $this->port2 = $details["port_instance_2"];
184
                $this->secret = $details["secret"];
185
                $this->radius_instance_1 = $details["radius_instance_1"];
186
                $this->radius_instance_2 = $details["radius_instance_2"];
187
                $this->radius_status_1 = 1;
188
                $this->radius_status_2 = 1;
189
                $this->status = AbstractDeployment::INACTIVE;
190
            } else {
191
                $this->port1 = $iterator->port_instance_1;
192
                $this->port2 = $iterator->port_instance_2;
193
                $this->secret = $iterator->secret;
194
                $this->radius_instance_1 = $iterator->radius_instance_1;
195
                $this->radius_instance_2 = $iterator->radius_instance_2;
196
                $this->radius_status_1 = $iterator->radius_status_1;
197
                $this->radius_status_2 = $iterator->radius_status_2;
198
                $this->status = $iterator->status;
199
            }
200
        }
201
        $server1details = $this->databaseHandle->exec("SELECT mgmt_hostname, radius_ip4, radius_ip6 FROM managed_sp_servers WHERE server_id = '$this->radius_instance_1'");
202
        while ($iterator2 = mysqli_fetch_object(/** @scrutinizer ignore-type */ $server1details)) {
203
            $this->host1_v4 = $iterator2->radius_ip4;
204
            $this->host1_v6 = $iterator2->radius_ip6;
205
            $this->radius_hostname_1 = $iterator2->mgmt_hostname;
206
        }
207
        $server2details = $this->databaseHandle->exec("SELECT mgmt_hostname, radius_ip4, radius_ip6 FROM managed_sp_servers WHERE server_id = '$this->radius_instance_2'");
208
        while ($iterator3 = mysqli_fetch_object(/** @scrutinizer ignore-type */ $server2details)) {
209
            $this->host2_v4 = $iterator3->radius_ip4;
210
            $this->host2_v6 = $iterator3->radius_ip6;
211
            $this->radius_hostname_2 = $iterator3->mgmt_hostname;
212
        }
213
        $thisLevelAttributes = $this->retrieveOptionsFromDatabase("SELECT DISTINCT option_name, option_lang, option_value, row 
214
                                            FROM $this->entityOptionTable
215
                                            WHERE $this->entityIdColumn = ?  
216
                                            ORDER BY option_name", "Profile");
217
        $tempAttribMergedIdP = $this->levelPrecedenceAttributeJoin($thisLevelAttributes, $this->idpAttributes, "IdP");
218
        $this->attributes = $this->levelPrecedenceAttributeJoin($tempAttribMergedIdP, $this->fedAttributes, "FED");
219
    }
220
221
    /**
222
     * finds a suitable server which is geographically close to the admin
223
     * 
224
     * @param array  $adminLocation      the current geographic position of the admin
225
     * @param string $federation         the federation this deployment belongs to
226
     * @param array  $blacklistedServers list of server to IGNORE
227
     * @return string the server ID
228
     * @throws Exception
229
     */
230
    private function findGoodServerLocation($adminLocation, $federation, $blacklistedServers) {
231
        // find a server near him (list of all servers with capacity, ordered by distance)
232
        // first, if there is a pool of servers specifically for this federation, prefer it
233
        $servers = $this->databaseHandle->exec("SELECT server_id, radius_ip4, radius_ip6, location_lon, location_lat FROM managed_sp_servers WHERE pool = '$federation'");
234
        
235
        $serverCandidates = [];
236
        while ($iterator = mysqli_fetch_object(/** @scrutinizer ignore-type */ $servers)) {
237
            $maxSupportedClients = DeploymentManaged::MAX_CLIENTS_PER_SERVER;
238
            if ($iterator->radius_ip4 == NULL || $iterator->radius_ip6 == NULL) {
239
                // half the amount of IP stacks means half the amount of FDs in use, so we can take twice as many
240
                $maxSupportedClients = $maxSupportedClients * 2;
241
            }
242
            $clientCount1 = $this->databaseHandle->exec("SELECT port_instance_1 AS tenants1 FROM deployment WHERE radius_instance_1 = '$iterator->server_id'");
243
            $clientCount2 = $this->databaseHandle->exec("SELECT port_instance_2 AS tenants2 FROM deployment WHERE radius_instance_2 = '$iterator->server_id'");
244
245
            $clients = $clientCount1->num_rows + $clientCount2->num_rows;
246
            if (in_array($iterator->server_id, $blacklistedServers)) {
247
                continue;
248
            }
249
            if ($clients < $maxSupportedClients) {
250
                $serverCandidates[IdPlist::geoDistance($adminLocation, ['lat' => $iterator->location_lat, 'lon' => $iterator->location_lon])] = $iterator->server_id;
251
            }
252
            if ($clients > $maxSupportedClients * 0.9) {
253
                $this->loggerInstance->debug(1, "A RADIUS server for Managed SP (" . $iterator->server_id . ") is serving at more than 90% capacity!");
254
            }
255
        }
256
        if (count($serverCandidates) == 0 && $federation != "DEFAULT") {
257
            // we look in the default pool instead
258
            // recursivity! Isn't that cool!
259
            return $this->findGoodServerLocation($adminLocation, "DEFAULT", $blacklistedServers);
260
        }
261
        if (count($serverCandidates) == 0) {
262
            throw new Exception("No available server found for new SP! $federation ".print_r($serverCandidates, true));
263
        }
264
        // put the nearest server on top of the list
265
        ksort($serverCandidates);
266
        $this->loggerInstance->debug(1, $serverCandidates);
267
        return array_shift($serverCandidates);
268
    }
269
270
    /**
271
     * initialises a new SP
272
     * 
273
     * @return array details of the SP as generated during initialisation
274
     * @throws Exception
275
     */
276
    private function initialise() {
277
        // find out where the admin is located approximately
278
        $ourLocation = ['lon' => 0, 'lat' => 0];
279
        $geoip = DeviceLocation::locateDevice();
280
        if ($geoip['status'] == 'ok') {
281
            $ourLocation = ['lon' => $geoip['geo']['lon'], 'lat' => $geoip['geo']['lat']];
282
        }
283
        $inst = new IdP($this->institution);
284
        $ourserver = $this->findGoodServerLocation($ourLocation, $inst->federation , []);
285
        // now, find an unused port in the preferred server
286
        $foundFreePort1 = 0;
287
        while ($foundFreePort1 == 0) {
288
            $portCandidate = random_int(1200, 65535);
289
            $check = $this->databaseHandle->exec("SELECT port_instance_1 FROM deployment WHERE radius_instance_1 = '" . $ourserver . "' AND port_instance_1 = $portCandidate");
290
            if (mysqli_num_rows(/** @scrutinizer ignore-type */ $check) == 0) {
291
                $foundFreePort1 = $portCandidate;
292
            }
293
        }
294
        $ourSecondServer = $this->findGoodServerLocation($ourLocation, $inst->federation , [$ourserver]);
295
        $foundFreePort2 = 0;
296
        while ($foundFreePort2 == 0) {
297
            $portCandidate = random_int(1200, 65535);
298
            $check = $this->databaseHandle->exec("SELECT port_instance_2 FROM deployment WHERE radius_instance_2 = '" . $ourSecondServer . "' AND port_instance_2 = $portCandidate");
299
            if (mysqli_num_rows(/** @scrutinizer ignore-type */ $check) == 0) {
300
                $foundFreePort2 = $portCandidate;
301
            }
302
        }
303
        // and make up a shared secret that is halfways readable
304
        $futureSecret = $this->randomString(16, "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
305
        $this->databaseHandle->exec("UPDATE deployment SET radius_instance_1 = '" . $ourserver . "', radius_instance_2 = '" . $ourSecondServer . "', port_instance_1 = $foundFreePort1, port_instance_2 = $foundFreePort2, secret = '$futureSecret' WHERE deployment_id = $this->identifier");
306
        return ["port_instance_1" => $foundFreePort1, "port_instance_2" => $foundFreePort2, "secret" => $futureSecret, "radius_instance_1" => $ourserver, "radius_instance_2" => $ourserver];
307
    }
308
309
    /**
310
     * update the last_changed timestamp for this deployment
311
     * 
312
     * @return void
313
     */
314
    public function updateFreshness() {
315
        $this->databaseHandle->exec("UPDATE deployment SET last_change = CURRENT_TIMESTAMP WHERE deployment_id = $this->identifier");
316
    }
317
318
    /**
319
     * gets the last-modified timestamp (useful for caching "dirty" check)
320
     * 
321
     * @return string the date in string form, as returned by SQL
322
     */
323
    public function getFreshness() {
324
        $execLastChange = $this->databaseHandle->exec("SELECT last_change FROM deployment WHERE deployment_id = $this->identifier");
325
        // SELECT always returns a resource, never a boolean
326
        if ($freshnessQuery = mysqli_fetch_object(/** @scrutinizer ignore-type */ $execLastChange)) {
327
            return $freshnessQuery->last_change;
328
        }
329
    }
330
331
    /**
332
     * Deletes the deployment from database
333
     * 
334
     * @return void
335
     */
336
    public function destroy() {
337
        $this->databaseHandle->exec("DELETE FROM deployment_option WHERE deployment_id = $this->identifier");
338
        $this->databaseHandle->exec("DELETE FROM deployment WHERE deployment_id = $this->identifier");
339
    }
340
341
    /**
342
     * deactivates the deployment.
343
     * TODO: needs to call the RADIUS server reconfiguration routines...
0 ignored issues
show
Coding Style Best Practice introduced by
Comments for TODO tasks are often forgotten in the code; it might be better to use a dedicated issue tracker.
Loading history...
344
     * 
345
     * @return void
346
     */
347
    public function deactivate() {
348
        $this->databaseHandle->exec("UPDATE deployment SET status = " . DeploymentManaged::INACTIVE . " WHERE deployment_id = $this->identifier");
349
    }
350
351
    /**
352
     * activates the deployment.
353
     * TODO: needs to call the RADIUS server reconfiguration routines...
0 ignored issues
show
Coding Style Best Practice introduced by
Comments for TODO tasks are often forgotten in the code; it might be better to use a dedicated issue tracker.
Loading history...
354
     * 
355
     * @return void
356
     */
357
    public function activate() {
358
        $this->databaseHandle->exec("UPDATE deployment SET status = " . DeploymentManaged::ACTIVE . " WHERE deployment_id = $this->identifier");
359
    }
360
361
    /**
362
     * determines the Operator-Name attribute content to use in the RADIUS config
363
     * 
364
     * @return string
365
     */
366
    public function getOperatorName() {
367
        $customAttrib = $this->getAttributes("managedsp:operatorname");
368
        if (count($customAttrib) == 0) {
369
            return "1sp.".$this->identifier."-".$this->institution.\config\ConfAssistant::SILVERBULLET['realm_suffix'];
370
        }
371
        return $customAttrib[0]["value"];
372
    }
373
    
374
    /**
375
     * send request to RADIUS configuration daemom
376
     *
377
     * @param integer $idx
0 ignored issues
show
Coding Style introduced by
Missing parameter comment
Loading history...
378
     * @param string $post 
0 ignored issues
show
Coding Style introduced by
Expected 2 spaces after parameter type; 1 found
Loading history...
379
     * @return string - response
380
     */
381
    public function sendToRADIUS($idx, $post) {
382
            
383
        $hostname = "radius_hostname_$idx";
384
        $ch = curl_init( "http://" . $this->$hostname );
385
        if ($ch) {
0 ignored issues
show
introduced by
$ch is of type false|resource, thus it always evaluated to false.
Loading history...
386
            curl_setopt( $ch, CURLOPT_POST, 1);
387
            curl_setopt( $ch, CURLOPT_POSTFIELDS, $post);
388
            $this->loggerInstance->debug(1, "Posting to http://" . $this->$hostname . ": $post\n");
389
            curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1);
390
            curl_setopt( $ch, CURLOPT_HEADER, 0);
391
            curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1);
392
            $res = curl_exec( $ch );
393
            if ($res === FALSE) {
394
                $res = 'FAILURE';
395
            }
396
            $this->loggerInstance->debug(1, "Response from FR configurator: $res\n");
397
        } else {
398
            $res = 'FAILURE';
399
        }
400
        $this->databaseHandle->exec("UPDATE deployment SET radius_status_$idx = " . ($res == 'OK'? \core\AbstractDeployment::RADIUS_OK : \core\AbstractDeployment::RADIUS_FAILURE) . " WHERE deployment_id = $this->identifier");
401
        return $res;
402
    }
403
    
404
    /**
405
     * prepare request to add/modify RADIUS settings for given deployment
406
     *
407
     * @param int $remove - the flag indicating remove request
408
     * @return array - index 1 indicate primary RADIUS status, index 2 backup RADIUS status
409
     */
410
    public function setRADIUSconfig($remove = 0) {
411
        $toPost = array(1 => '', 2 => '');
412
        $toPost[1] = 'instid=' . $this->institution . '&deploymentid=' . $this->identifier . '&secret=' . $this->secret . '&country=' . $this->getAttributes("internal:country")[0]['value'] . '&';
413
        if ($remove) {
414
            $toPost[1] = $toPost[1] . 'remove=1&';
415
        } else {
416
            if ($this->getAttributes("managedsp:operatorname")[0]['value'] ?? NULL) {
417
                $toPost[1] = $toPost[1] . 'operatorname=' . $this->getAttributes("managedsp:operatorname")[0]['value'] . '&';
418
            }
419
            if ($this->getAttributes("managedsp:vlan")[0]['value'] ?? NULL) {
420
                $idp = new IdP($this->institution);
421
                $allProfiles = $idp->listProfiles(TRUE);
422
                $allRealms = [];
423
                if (($this->getAttributes("managedsp:realmforvlan") ?? NULL)) {
424
                    $allRealms = array_values(array_unique(array_column($this->getAttributes("managedsp:realmforvlan"), "value")));
425
                }
426
                foreach ($allProfiles as $profile) {
427
                    if ($realm = ($profile->getAttributes("internal:realm")[0]['value'] ?? NULL)) {
428
                        if (!in_array($realm, $allRealms)) {
429
                            $allRealms[] = $realm;
430
                        }
431
                    }
432
                }
433
                if (!empty($allRealms)) {
434
                    $toPost[1] = $toPost[1] . 'vlan=' . $this->getAttributes("managedsp:vlan")[0]['value'] . '&';
435
                    $toPost[1] = $toPost[1] . 'realmforvlan[]=' . implode('&realmforvlan[]=', $allRealms) . '&';
436
                }
437
            }
438
        }
439
        $toPost[2] = $toPost[1];
440
        $toPost[1] = $toPost[1] . 'port=' . $this->port1;
441
        $toPost[2] = $toPost[2] . 'port=' . $this->port2;
442
        $response = array();
443
        for ($idx=1; $idx<=2; $idx++) {
444
            $response[$idx] = $this->sendToRADIUS($idx, $toPost[$idx]);
445
        }
446
        return $response;
447
    }
448
}
449