Passed
Push — master ( 28eddd...1e0b05 )
by Stefan
08:22
created

Telepath::checkFlrServerStatus()   B

Complexity

Conditions 6
Paths 9

Size

Total Lines 25
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 25
rs 8.439
c 0
b 0
f 0
cc 6
eloc 19
nc 9
nop 1
1
<?php
2
3
/*
4
 * ******************************************************************************
5
 * Copyright 2011-2017 DANTE Ltd. and GÉANT on behalf of the GN3, GN3+, GN4-1 
6
 * and GN4-2 consortia
7
 *
8
 * License: see the web/copyright.php file in the file structure
9
 * ******************************************************************************
10
 */
11
12
namespace core\diag;
13
14
use \Exception;
15
16
require_once(dirname(dirname(__DIR__)) . "/config/_config.php");
17
18
/**
19
 * The overall coordination class that runs all kinds of tests to find out where
20
 * and what is wrong. Operates on the realm of a user. Can do more magic if it
21
 * also knows which federation the user is currently positioned in, or even 
22
 * which exact hotspot to analyse.
23
 */
24
class Telepath extends AbstractTest {
25
26
    private $realm;
27
    private $visitedFlr;
28
    private $visitedHotspot;
29
    private $catProfile;
30
    private $dbIdP;
31
32
    /**
33
     *
34
     * @var string|null
35
     */
36
    private $idPFederation;
37
    private $testsuite;
38
39
    /**
40
     * prime the Telepath with info it needs to know to successfully meditate over the problem
41
     * @param string $realm the realm of the user
42
     * @param string|null $visitedFlr which NRO is the user visiting
43
     * @param string|null $visitedHotspot external DB ID of the hotspot he visited
44
     */
45
    public function __construct(string $realm, $visitedFlr = NULL, $visitedHotspot = NULL) {
46
        // Telepath is the first one in a chain, no previous inputs allowed
47
        if (isset($_SESSION) && isset($_SESSION["SUSPECTS"])) {
48
            unset($_SESSION["SUSPECTS"]);
49
        }
50
        if (isset($_SESSION) && isset($_SESSION["EVIDENCE"])) {
51
            unset($_SESSION["EVIDENCE"]);
52
        }
53
        // now fill with default values
54
        parent::__construct();
55
        $this->realm = $realm;
56
        $this->visitedFlr = $visitedFlr;
57
        $this->visitedHotspot = $visitedHotspot;
58
        $links = \core\Federation::determineIdPIdByRealm($realm);
59
        $this->catProfile = $links["CAT"];
60
        $this->dbIdP = $links["EXTERNAL"];
61
        $this->idPFederation = $links["FEDERATION"];
62
        // this is NULL if the realm is not known in either DB
63
        // if so, let's try a regex to extract the ccTLD if any
64
        $matches = [];
65
        if ($this->idPFederation === NULL && preg_match("/\.(..)$/", $realm, $matches)) {
66
            $this->idPFederation = strtoupper($matches[1]);
67
        }
68
        $this->loggerInstance->debug(4, "XYZ: IdP-side NRO is " . $this->idPFederation . "\n");
69
    }
70
71
    /* The eduroam OT monitoring has the following return codes:
72
     * 
73
74
      Status codes
75
76
      0 - O.K.
77
      -1 - Accept O.K. Reject No
78
      -2 - Reject O.K. Accept No
79
      -3 - Accept No Reject No
80
      -9 - system error
81
      -10 - Accept O.K. Reject timeou
82
      -11 - Accept O.K. Reject no EAP
83
      -20 - Reject O.K. Accept timeou
84
      -21 - Reject O.K. Accept no EAP
85
      -31 - Accept No  Reject timeou
86
      -32 - Accept Timeout Reject no
87
      -33 - Accept Timeout Reject timeou
88
      -35 - Accept No Reject no EAP
89
      -36 - Reject No Accept no EAP
90
      -37 - Reject No EAP Accept no EAP
91
      -40 - UDP test error
92
93
     */
94
95
    /**
96
     * ask the monitoring API about the things it knows
97
     * @param string $type which type of test to execute
98
     * @param string $param1 test-specific parameter number 1, if any
99
     * @param string $param2 test-specific parameter number 2, if any
100
     * @return array
101
     */
102
    private function genericAPIStatus($type, $param1 = NULL, $param2 = NULL) {
103
        $endpoints = [
104
            'tlr_test' => "https://monitor.eduroam.org/mapi/index.php?type=tlr_test&tlr=$param1",
105
            'federation_via_tlr' => "https://monitor.eduroam.org/mapi/index.php?type=federation_via_tlr&federation=$param1",
106
            'flrs_test' => "https://monitor.eduroam.org/mapi/index.php?type=flrs_test&federation=$param1",
107
            'flr_by_federation' => "https://monitor.eduroam.org/mapi/index.php?type=flr_by_federation&federation=$param2&with=$param1",
108
        ];
109
        $ignore = [
110
            'tlr_test' => 'tlr',
111
            'federation_via_tlr' => 'fed',
112
            'flrs_test' => 'fed',
113
            'flr_by_federation' => 'fed',
114
        ];
115
        $this->loggerInstance->debug(4, "Doing Monitoring API check with $endpoints[$type]\n");
116
        $jsonResult = \core\common\OutsideComm::downloadFile($endpoints[$type]);
117
        $this->loggerInstance->debug(4, "Monitoring API Result: $jsonResult\n");
118
        $retval = [];
119
        if ($jsonResult === FALSE) { // monitoring API didn't respond at all!
120
            $retval["STATUS"] = AbstractTest::STATUS_MONITORINGFAIL;
121
            return $retval;
122
        }
123
        $decoded = json_decode($jsonResult, TRUE);
124
        $retval["RAW"] = $decoded;
125
        $atLeastOneFunctional = FALSE;
126
        $allFunctional = TRUE;
127
        if (!isset($decoded[$type]) || isset($decoded['ERROR'])) {
128
            $retval["STATUS"] = AbstractTest::STATUS_MONITORINGFAIL;
129
            return $retval;
130
        }
131
        foreach ($decoded[$type] as $instance => $resultset) {
132
            if ($instance == $ignore[$type]) {
133
                // don't care
134
                continue;
135
            }
136
            // TLR test has statuscode on this level, otherwise need to recurse
137
            // one more level
138
            switch ($type) {
139
                case "tlr_test":
140
                    switch ($resultset['status_code']) {
141
                        case 0:
142
                            $atLeastOneFunctional = TRUE;
143
                            break;
144
                        case 9: // monitoring itself has an error, no effect on our verdict
145
                        case -1: // Reject test fails, but we diagnose supposed-working connection, so no effect on our verdict
146
                        case -10: // same as previous
147
                        case -11: // same as previous
148
                            break;
149
                        default:
150
                            $allFunctional = FALSE;
151
                    }
152
                    break;
153
                default:
154
                    foreach ($resultset as $particularInstance => $particularResultset) {
155
                        switch ($particularResultset['status_code']) {
156
                            case 0:
157
                                $atLeastOneFunctional = TRUE;
158
                                break;
159
                            case 9: // monitoring itself has an error, no effect on our verdict
160
                            case -1: // Reject test fails, but we diagnose supposed-working connection, so no effect on our verdict
161
                            case -10: // same as previous
162
                            case -11: // same as previous
163
                                break;
164
                            default:
165
                                $allFunctional = FALSE;
166
                        }
167
                    }
168
            }
169
        }
170
171
        if ($allFunctional) {
172
            $retval["STATUS"] = AbstractTest::STATUS_GOOD;
173
            return $retval;
174
        }
175
        if ($atLeastOneFunctional) {
176
            $retval["STATUS"] = AbstractTest::STATUS_PARTIAL;
177
            return $retval;
178
        }
179
        $retval["STATUS"] = AbstractTest::STATUS_DOWN;
180
        return $retval;
181
    }
182
183
    /**
184
     * Are the ETLR servers in order?
185
     * @return array
186
     */
187
    private function checkEtlrStatus() {
188
        // TODO: we always check the European TLRs even though the connection in question might go via others and/or this one
189
        // needs a table to determine what goes where :-(
190
        $ret = $this->genericAPIStatus("tlr_test", "TLR_EU");
191
        $this->additionalFindings[AbstractTest::INFRA_ETLR][] = $ret;
192
        switch ($ret["STATUS"]) {
193
            case AbstractTest::STATUS_GOOD:
194
                unset($this->possibleFailureReasons[AbstractTest::INFRA_ETLR]);
195
                break;
196
            case AbstractTest::STATUS_PARTIAL:
197
            case AbstractTest::STATUS_MONITORINGFAIL:
198
                // one of the ETLRs is down, or there is a failure in the monitoring system? 
199
                // This probably doesn't impact the user unless he's unlucky and has his session fall into failover.
200
                // keep ETLR as a possible problem with original probability
201
                break;
202
            case AbstractTest::STATUS_DOWN:
203
                // Oh! Well if it is not international roaming, that still doesn't have an effect /in this case/. 
204
                if ($this->idPFederation == $this->visitedFlr) {
205
                    unset($this->possibleFailureReasons[AbstractTest::INFRA_ETLR]);
206
                    break;
207
                }
208
                // But it is about int'l roaming, and we are spot on here.
209
                // Raise probability by much (even monitoring is sometimes wrong, or a few minutes behind reality)
210
                $this->possibleFailureReasons[AbstractTest::INFRA_ETLR] = 0.95;
211
        }
212
    }
213
214
    /**
215
     * Is the uplink between an NRO server and the ETLRs in order?
216
     * @param string $whichSide
217
     * @return array
218
     */
219
    private function checkFedEtlrUplink($whichSide) {
220
        // TODO: we always check the European TLRs even though the connection in question might go via others and/or this one
221
        // needs a table to determine what goes where :-(
222
        switch ($whichSide) {
223
            case AbstractTest::INFRA_NRO_IDP:
224
                $fed = $this->idPFederation;
225
                $linkVariant = AbstractTest::INFRA_LINK_ETLR_NRO_IDP;
226
                break;
227
            case AbstractTest::INFRA_NRO_SP:
228
                $fed = $this->visitedFlr;
229
                $linkVariant = AbstractTest::INFRA_LINK_ETLR_NRO_SP;
230
                break;
231
            default:
232
                throw new Exception("This function operates on the IdP- or SP-side FLR, nothing else!");
233
        }
234
235
        $ret = $this->genericAPIStatus("federation_via_tlr", $fed);
236
        $this->additionalFindings[AbstractTest::INFRA_NRO_IDP][] = $ret;
237
        switch ($ret["STATUS"]) {
238
            case AbstractTest::STATUS_GOOD:
239
                unset($this->possibleFailureReasons[$whichSide]);
240
                unset($this->possibleFailureReasons[$linkVariant]);
241
                break;
242
            case AbstractTest::STATUS_PARTIAL:
243
                // a subset of the FLRs is down? This probably doesn't impact the user unless he's unlucky and has his session fall into failover.
244
                // keep FLR as a possible problem with original probability
245
                break;
246
            case AbstractTest::STATUS_DOWN:
247
                // Raise probability by much (even monitoring is sometimes wrong, or a few minutes behind reality)
248
                // if earlier test found the server itself to be the problem, keep it, otherwise put the blame on the link
249
                if ($this->possibleFailureReasons[$whichSide] != 0.95) {
250
                    $this->possibleFailureReasons[$linkVariant] = 0.95;
251
                }
252
        }
253
    }
254
255
    /**
256
     * Is the NRO server itself in order?
257
     * @param string $whichSide
258
     * @return array
259
     */
260
    private function checkFlrServerStatus($whichSide) {
261
        switch ($whichSide) {
262
            case AbstractTest::INFRA_NRO_IDP:
263
                $fed = $this->idPFederation;
264
                break;
265
            case AbstractTest::INFRA_NRO_SP:
266
                $fed = $this->visitedFlr;
267
                break;
268
            default:
269
                throw new Exception("This function operates on the IdP- or SP-side FLR, nothing else!");
270
        }
271
272
        $ret = $this->genericAPIStatus("flrs_test", $fed);
273
        $this->additionalFindings[$whichSide][] = $ret;
274
        switch ($ret["STATUS"]) {
275
            case AbstractTest::STATUS_GOOD:
276
                unset($this->possibleFailureReasons[$whichSide]);
277
                break;
278
            case AbstractTest::STATUS_PARTIAL:
279
                // a subset of the FLRs is down? This probably doesn't impact the user unless he's unlucky and has his session fall into failover.
280
                // keep FLR as a possible problem with original probability
281
                break;
282
            case AbstractTest::STATUS_DOWN:
283
                // Raise probability by much (even monitoring is sometimes wrong, or a few minutes behind reality)
284
                $this->possibleFailureReasons[$whichSide] = 0.95;
285
        }
286
    }
287
288
    /**
289
     * Does authentication traffic flow between a given source and destination NRO?
290
     * @return array
291
     */
292
    private function checkNROFlow() {
293
        return $this->genericAPIStatus("flr_by_federation", $this->idPFederation, $this->visitedFlr);
294
    }
295
296
    /**
297
     * Runs the CAT-internal diagnostics tests. Determines the state of the 
298
     * realm (and indirectly that of the links and statuses of involved proxies
299
     * and returns a judgment whether external Monitoring API tests are warranted
300
     * or not
301
     * @return boolean TRUE if external tests have to be run
302
     */
303
    private function CATInternalTests() {
304
        // we are expecting to get a REJECT from all runs, because that means the packet got through to the IdP.
305
        // (the ETLR sometimes does a "Reject instead of Ignore" but that is filtered out and changed into a timeout
306
        // by the test suite automatically, so it does not disturb the measurement)
307
        // If that's true, we can exclude two sources of problems (both proxy levels). Hooray!
308
        $allAreConversationReject = TRUE;
309
        $atLeastOneConversationReject = FALSE;
310
311
        foreach (CONFIG_DIAGNOSTICS['RADIUSTESTS']['UDP-hosts'] as $probeindex => $probe) {
312
            $reachCheck = $this->testsuite->udpReachability($probeindex);
313
            if ($reachCheck != RADIUSTests::RETVAL_CONVERSATION_REJECT) {
314
                $allAreConversationReject = FALSE;
315
            } else {
316
                $atLeastOneConversationReject = TRUE;
317
            }
318
319
            $this->additionalFindings[AbstractTest::INFRA_ETLR][] = ["DETAIL" => $this->testsuite->consolidateUdpResult($probeindex)];
320
            $this->additionalFindings[AbstractTest::INFRA_NRO_IDP][] = ["DETAIL" => $this->testsuite->consolidateUdpResult($probeindex)];
321
            $this->additionalFindings[AbstractTest::INFRA_IDP_RADIUS][] = ["DETAIL" => $this->testsuite->consolidateUdpResult($probeindex)];
322
        }
323
324
        if ($allAreConversationReject) {
325
            $this->additionalFindings[AbstractTest::INFRA_ETLR][] = ["CONNCHECK" => RADIUSTests::RETVAL_CONVERSATION_REJECT];
326
            $this->additionalFindings[AbstractTest::INFRA_NRO_IDP][] = ["CONNCHECK" => RADIUSTests::RETVAL_CONVERSATION_REJECT];
327
            $this->additionalFindings[AbstractTest::INFRA_IDP_RADIUS][] = ["CONNCHECK" => RADIUSTests::RETVAL_CONVERSATION_REJECT];
328
            $this->additionalFindings[AbstractTest::INFRA_LINK_ETLR_NRO_IDP][] = ["LINKCHECK" => RADIUSTests::L_OK];
329
            // we have actually reached an IdP, so all links are good, and the
330
            // realm is routable in eduroam. So even if it exists in neither DB
331
            // we can exclude the NONEXISTENTREALM case
332
            unset($this->possibleFailureReasons[AbstractTest::INFRA_ETLR]);
333
            unset($this->possibleFailureReasons[AbstractTest::INFRA_NRO_IDP]);
334
            unset($this->possibleFailureReasons[AbstractTest::INFRA_LINK_ETLR_NRO_IDP]);
335
            unset($this->possibleFailureReasons[AbstractTest::INFRA_NONEXISTENTREALM]);
336
        }
337
338
        if ($atLeastOneConversationReject) {
339
            // at least we can be sure it exists
340
            unset($this->possibleFailureReasons[AbstractTest::INFRA_NONEXISTENTREALM]);
341
            // It could still be an IdP RADIUS problem in that some cert oddities 
342
            // in combination with the device lead to a broken auth
343
            // if there is nothing beyond the "REMARK" level, then it's not an IdP problem
344
            // otherwise, add the corresponding warnings and errors to $additionalFindings
345
            switch ($this->additionalFindings[AbstractTest::INFRA_IDP_RADIUS][0]['DETAIL']['level']) {
346
                case RADIUSTests::L_OK:
347
                case RADIUSTests::L_REMARK:
348
                    // both are fine - the IdP is working and the user problem
349
                    // is not on the IdP RADIUS level
350
                    $this->additionalFindings[AbstractTest::INFRA_IDP_RADIUS][] = ["ODDITYLEVEL" => $this->additionalFindings[AbstractTest::INFRA_IDP_RADIUS][0]['DETAIL']['level']];
351
                    unset($this->possibleFailureReasons[AbstractTest::INFRA_IDP_RADIUS]);
352
                    break;
353
                case RADIUSTests::L_WARN:
354
                    $this->additionalFindings[AbstractTest::INFRA_IDP_RADIUS][] = ["ODDITYLEVEL" => RADIUSTests::L_WARN];
355
                    $this->possibleFailureReasons[AbstractTest::INFRA_IDP_RADIUS] = 0.3; // possibly we found the culprit - if RADIUS server is misconfigured AND user is on a device which reacts picky about exactly this oddity.
356
                    break;
357
                case RADIUSTests::L_ERROR:
358
                    $this->additionalFindings[AbstractTest::INFRA_IDP_RADIUS][] = ["ODDITYLEVEL" => RADIUSTests::L_ERROR];
359
                    $this->possibleFailureReasons[AbstractTest::INFRA_IDP_RADIUS] = 0.8; // errors are never good, so we can be reasonably sure we've hit the spot!
360
            }
361
        }
362
    }
363
364
    private function determineTestsuiteParameters() {
365
        if ($this->catProfile > 0) {
366
            $profileObject = \core\ProfileFactory::instantiate($this->catProfile);
367
            $readinessLevel = $profileObject->readinessLevel();
368
369
            switch ($readinessLevel) {
370
                case \core\AbstractProfile::READINESS_LEVEL_SHOWTIME: 
371
                    // fall-througuh intended: use the data even if non-public but complete
372
                case \core\AbstractProfile::READINESS_LEVEL_SUFFICIENTCONFIG:
373
                    $this->additionalFindings[AbstractTest::INFRA_IDP_RADIUS][] = ["Profile" => $profileObject->identifier];
374
                    $this->testsuite = new RADIUSTests($this->realm, $profileObject->getRealmCheckOuterUsername(), $profileObject->getEapMethodsinOrderOfPreference(1), $profileObject->getCollapsedAttributes()['eap:server_name'], $profileObject->getCollapsedAttributes()["eap:ca_file"]);
375
                    break;
376
                case \core\AbstractProfile::READINESS_LEVEL_NOTREADY:
377
                    $this->additionalFindings[AbstractTest::INFRA_IDP_RADIUS][] = ["Profile" => "UNCONCLUSIVE"];
378
                    $this->testsuite = new RADIUSTests($this->realm, "anonymous@" . $this->realm);
379
                    break;
380
                default:
381
            }
382
        } else {
383
            $this->testsuite = new RADIUSTests($this->realm, "anonymous@" . $this->realm);
384
        }
385
    }
386
387
    /**
388
     * Does the main meditation job
389
     * @return array the findings
390
     */
391
    public function magic() {
392
393
        // simple things first: do we know anything about the realm, either
394
        // because it's a CAT participant or because it's in the eduroam DB?
395
        // if so, we can exclude the INFRA_NONEXISTENTREALM cause
396
        $this->additionalFindings[AbstractTest::INFRA_NONEXISTENTREALM]['DATABASE_STATUS'] = ["ID1" => $this->catProfile, "ID2" => $this->dbIdP];
397
        if ($this->catProfile != \core\Federation::UNKNOWN_IDP || $this->dbIdP != \core\Federation::UNKNOWN_IDP) {
398
            unset($this->possibleFailureReasons[AbstractTest::INFRA_NONEXISTENTREALM]);
399
        }
400
        // do we operate on a non-ambiguous, fully configured CAT profile? Then
401
        // we run the more thorough check, otherwise the shallow one.
402
        $this->determineTestSuiteParameters();
403
        // let's do the least amount of testing needed:
404
        // - The CAT reachability test already covers ELTRs, IdP NRO level and the IdP itself.
405
        //   if the realm maps to a CAT IdP, we can run the more thorough tests; otherwise just
406
        //   the normal shallow ones
407
        // these are the normal "realm check" tests covering ETLR, LINK_NRO_IDP, NRO, IDP_RADIUS
408
        $this->CATInternalTests();
409
        // - if the test does NOT go through, we need to find out which of the three is guilty
410
        // - then, the international "via ETLR" check can be used to find out if the IdP alone
411
        //   is guilty. If that one fails, the direct monitoring of servers and ETLRs themselves
412
        //   closes the loop.
413
        // let's see if the ETLRs are up
414
        if (array_key_exists(AbstractTest::INFRA_ETLR, $this->possibleFailureReasons)) {
415
            $this->checkEtlrStatus();
416
        }
417
418
        // then let's check the IdP's FLR, if we know the IdP federation at all
419
        if ($this->idPFederation !== NULL) {
420
            if (array_key_exists(AbstractTest::INFRA_NRO_IDP, $this->possibleFailureReasons)) {
421
                // first the direct connectivity to the server
422
                $this->checkFlrServerStatus(AbstractTest::INFRA_NRO_IDP);
423
            }
424
            // now let's theck the link
425
            if (array_key_exists(AbstractTest::INFRA_LINK_ETLR_NRO_IDP, $this->possibleFailureReasons)) {
426
                $this->checkFedEtlrUplink(AbstractTest::INFRA_NRO_IDP);
427
            }
428
        }
429
        // now, if we know the country the user is currently in, let's see 
430
        // if the NRO SP-side is up
431
        if ($this->visitedFlr !== NULL) {
432
            $this->checkFlrServerStatus(AbstractTest::INFRA_NRO_SP);
433
            // and again its uplink to the ETLR
434
            $this->checkFedEtlrUplink(AbstractTest::INFRA_NRO_SP);
435
        }
436
        // the last thing we can do (but it's a bit redundant): check the country-to-country link
437
        // it's only needed if all three and their links are up, but we want to exclude funny routing blacklists 
438
        // which occur only in the *combination* of source and dest
439
        // if there is an issue at that point, blame the SP: once a request
440
        // would have reached the ETLRs, things would be all good (assuming
441
        // perfection on the ETLRs here!). So the SP has a wrong config.
442
        if ($this->idPFederation !== NULL &&
443
                $this->visitedFlr !== NULL &&
444
                !array_key_exists(AbstractTest::INFRA_ETLR, $this->possibleFailureReasons) &&
445
                !array_key_exists(AbstractTest::INFRA_LINK_ETLR_NRO_IDP, $this->possibleFailureReasons) &&
446
                !array_key_exists(AbstractTest::INFRA_NRO_IDP, $this->possibleFailureReasons) &&
447
                !array_key_exists(AbstractTest::INFRA_LINK_ETLR_NRO_SP, $this->possibleFailureReasons) &&
448
                !array_key_exists(AbstractTest::INFRA_NRO_SP, $this->possibleFailureReasons)
449
        ) {
450
            $countryToCountryStatus = $this->checkNROFlow();
451
            $this->additionalFindings[AbstractTest::INFRA_NRO_SP][] = $countryToCountryStatus;
452
            $this->additionalFindings[AbstractTest::INFRA_ETLR][] = $countryToCountryStatus;
453
            $this->additionalFindings[AbstractTest::INFRA_NRO_IDP][] = $countryToCountryStatus;
454
            switch ($countryToCountryStatus["STATUS"]) {
455
                case AbstractTest::STATUS_GOOD:
456
                    // all routes work
457
                    break;
458
                case AbstractTest::STATUS_PARTIAL:
459
                // at least one, or even all have a routing problem
460
                case AbstractTest::STATUS_DOWN:
461
                    // that's rather telling.
462
                    $this->possibleFailureReasons[AbstractTest::INFRA_NRO_SP] = 0.95;
463
            }
464
        }
465
466
        $this->normaliseResultSet();
467
468
        $_SESSION["SUSPECTS"] = $this->possibleFailureReasons;
469
        $_SESSION["EVIDENCE"] = $this->additionalFindings;
470
        return ["SUSPECTS" => $this->possibleFailureReasons, "EVIDENCE" => $this->additionalFindings];
471
    }
472
473
}
474