1: | <?php declare(strict_types=1); |
2: | |
3: | |
4: | |
5: | |
6: | |
7: | |
8: | |
9: | |
10: | |
11: | |
12: | |
13: | |
14: | use Opcenter\Net\Fail2ban; |
15: | use Opcenter\Net\Firewall; |
16: | use Opcenter\Net\Firewall\Delegated; |
17: | use Opcenter\Net\Firewall\Ipset; |
18: | use Opcenter\Net\Ip6; |
19: | use Opcenter\Net\IpCommon; |
20: | use Opcenter\Service\Contracts\DefaultNullable; |
21: | |
22: | |
23: | |
24: | |
25: | |
26: | |
27: | class Rampart_Module extends Module_Skeleton |
28: | { |
29: | public const FAIL2BAN_IPT_PREFIX = RAMPART_PREFIX; |
30: | public const FAIL2BAN_DRIVER = RAMPART_DRIVER; |
31: | public const FAIL2BAN_CACHE_KEY = 'f2b'; |
32: | |
33: | public const PLIST_NAMES = [Delegated::IPSET_NAME, 'blacklist', Delegated::IPSET_NAME . '6', 'blacklist6']; |
34: | protected $confMapping; |
35: | |
36: | protected $exportedFunctions = [ |
37: | '*' => PRIVILEGE_ADMIN | PRIVILEGE_SITE, |
38: | 'ban' => PRIVILEGE_ADMIN, |
39: | 'blacklist' => PRIVILEGE_ADMIN, |
40: | 'whitelist' => PRIVILEGE_ADMIN, |
41: | 'get_plist' => PRIVILEGE_ADMIN, |
42: | 'get_jail_entries' => PRIVILEGE_ADMIN, |
43: | 'flush' => PRIVILEGE_ADMIN, |
44: | 'get_delegated_list' => PRIVILEGE_SITE, |
45: | 'can_delegate' => PRIVILEGE_SITE, |
46: | 'max_delegations' => PRIVILEGE_SITE, |
47: | 'temp' => PRIVILEGE_ADMIN|PRIVILEGE_SITE, |
48: | 'enabled' => PRIVILEGE_ALL |
49: | ]; |
50: | |
51: | public function __construct() |
52: | { |
53: | parent::__construct(); |
54: | if (static::FAIL2BAN_DRIVER !== 'ipset') { |
55: | $this->exportedFunctions = [ |
56: | 'whitelist' => PRIVILEGE_NONE, |
57: | 'blacklist' => PRIVILEGE_NONE |
58: | ] + $this->exportedFunctions; |
59: | } else if (version_compare(APNSCP_VERSION, '3.1', '>=')) { |
60: | $this->exportedFunctions['whitelist'] |= PRIVILEGE_SITE; |
61: | } |
62: | |
63: | if (RAMPART_USER_DISCOVERY) { |
64: | $this->exportedFunctions = array_merge($this->exportedFunctions, [ |
65: | 'get_reason' => PRIVILEGE_ALL, |
66: | 'banned_services' => PRIVILEGE_ALL, |
67: | 'is_banned' => PRIVILEGE_ALL, |
68: | 'unban' => PRIVILEGE_ALL, |
69: | 'ban_reason' => PRIVILEGE_ALL |
70: | ]); |
71: | } |
72: | } |
73: | |
74: | |
75: | |
76: | |
77: | |
78: | |
79: | |
80: | |
81: | public function is_banned(string $ip = null, string $jail = null): bool |
82: | { |
83: | if (!IS_CLI) { |
84: | return $this->query('rampart_is_banned', $ip, $jail); |
85: | } |
86: | if (false === ($ip = $this->checkInput($ip, $jail))) { |
87: | return false; |
88: | } |
89: | |
90: | return count($this->getMatches($ip, $jail)) > 0; |
91: | } |
92: | |
93: | |
94: | |
95: | |
96: | |
97: | |
98: | |
99: | public function banned_services(string $ip = null): array |
100: | { |
101: | if (!IS_CLI) { |
102: | return $this->query('rampart_banned_services', $ip); |
103: | } |
104: | if (false === ($ip = $this->checkInput($ip))) { |
105: | return []; |
106: | } |
107: | |
108: | return $this->getMatches($ip); |
109: | } |
110: | |
111: | |
112: | |
113: | |
114: | |
115: | |
116: | |
117: | |
118: | public function get_reason(string $ip = null, string $jail = null): ?string |
119: | { |
120: | if (!IS_CLI) { |
121: | return $this->query('rampart_get_reason', $ip, $jail); |
122: | } |
123: | if (!RAMPART_SHOW_REASON && !($this->permission_level & PRIVILEGE_ADMIN)) { |
124: | return null; |
125: | } |
126: | |
127: | if (false === ($ip = $this->checkInput($ip))) { |
128: | return null; |
129: | } |
130: | |
131: | $matches = $this->getMatches($ip); |
132: | |
133: | $db = Fail2ban::getDatabaseHandler(); |
134: | $fragment = "ip = '$ip'"; |
135: | if ($jail) { |
136: | $fragment .= ' AND jail = ' . $db->quote($jail) . ''; |
137: | } |
138: | |
139: | $query = "SELECT data FROM bans WHERE $fragment ORDER BY timeofban DESC LIMIT 1"; |
140: | $rs = $db->query($query); |
141: | if (!$rs) { |
142: | return null; |
143: | } |
144: | $data = json_decode(array_get($rs->fetch(\PDO::FETCH_ASSOC), 'data', 'null'), true); |
145: | $last = array_get($data, 'matches', []); |
146: | if (!$data) { |
147: | return null; |
148: | } |
149: | |
150: | do { |
151: | $last = array_pop($last); |
152: | } while (is_array($last)); |
153: | |
154: | return $last; |
155: | } |
156: | |
157: | |
158: | |
159: | |
160: | |
161: | |
162: | |
163: | |
164: | protected function checkInput(string $ip = null, string $jail = null) |
165: | { |
166: | if ($this->permission_level & (PRIVILEGE_SITE|PRIVILEGE_USER)) { |
167: | if ($ip) { |
168: | return error('IP address may not be specified if site admin'); |
169: | } |
170: | if ($jail) { |
171: | return error('jail may not be specified if site admin'); |
172: | } |
173: | if (!$this->enabled()) { |
174: | return error('user may not remove block'); |
175: | } |
176: | } |
177: | if (!$ip) { |
178: | $ip = \Auth::client_ip(); |
179: | } |
180: | if (!$ip) { |
181: | report('Odd?' . var_export($_ENV, true)); |
182: | } |
183: | if (false === inet_pton($ip)) { |
184: | return error("invalid IP address `%s'", $ip); |
185: | } |
186: | |
187: | return $ip; |
188: | } |
189: | |
190: | |
191: | |
192: | |
193: | |
194: | |
195: | |
196: | |
197: | private function getMatches(string $ip, string $jail = null): array |
198: | { |
199: | $banned = []; |
200: | if ($jail) { |
201: | $jail = static::FAIL2BAN_IPT_PREFIX . $jail; |
202: | } |
203: | $matches = (new Firewall)->getEntriesFromChain($jail); |
204: | if ($jail) { |
205: | $matches = [$jail => $matches]; |
206: | } |
207: | |
208: | foreach ($matches as $chain => $records) { |
209: | foreach ($records as $record) { |
210: | if ($record->getHost() === $ip && $record->isBlocked()) { |
211: | $banned[$chain] = 1; |
212: | } |
213: | } |
214: | } |
215: | |
216: | return array_keys($banned); |
217: | } |
218: | |
219: | |
220: | |
221: | |
222: | |
223: | |
224: | |
225: | |
226: | public function ban(string $ip, string $jail): bool |
227: | { |
228: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
229: | return error("Firewall may not be modified in demo mode"); |
230: | } |
231: | |
232: | if (!IS_CLI) { |
233: | return $this->query('rampart_ban', $ip, $jail); |
234: | } |
235: | if (!in_array($jail, $this->get_jails(), true)) { |
236: | return error("Unknown jail `%s'", $jail); |
237: | } |
238: | if (false === $this->checkInput($ip, $jail)) { |
239: | return false; |
240: | } |
241: | $ret = \Util_Process_Safe::exec(['fail2ban-client', 'set', '%s', 'banip', '%s'], $jail, $ip); |
242: | |
243: | return $ret['success']; |
244: | } |
245: | |
246: | |
247: | |
248: | |
249: | |
250: | |
251: | |
252: | |
253: | public function temp(string $ip = null, int $duration = 7200): bool |
254: | { |
255: | if ($duration < 1) { |
256: | return error('Non-sensical usage of %s: %s', 'duration', $duration); |
257: | } |
258: | $ip = $ip ?? \Auth::client_ip(); |
259: | |
260: | if (\in_array($ip, $this->get_delegated_list(), true)) { |
261: | return true; |
262: | } |
263: | |
264: | if (!$this->whitelist($ip, 'add')) { |
265: | return false; |
266: | } |
267: | |
268: | return $this->pman_schedule_api_cmd('rampart_whitelist', [$ip, 'remove'], "now + ${duration} seconds"); |
269: | } |
270: | |
271: | |
272: | |
273: | |
274: | |
275: | |
276: | |
277: | |
278: | public function whitelist(string $ip = null, string $mode = 'add'): bool |
279: | { |
280: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
281: | return error("Firewall may not be modified in demo mode"); |
282: | } |
283: | |
284: | if (!IS_CLI) { |
285: | if (!$this->query('rampart_whitelist', $ip, $mode)) { |
286: | return false; |
287: | } |
288: | $this->getAuthContext()->reset(); |
289: | return true; |
290: | } |
291: | |
292: | if (!$ip) { |
293: | $ip = \Auth::client_ip(); |
294: | } |
295: | |
296: | if ($mode !== 'add' && $mode !== 'remove' && $mode !== 'delete') { |
297: | return error('Unknown whitelist operation %s', $mode); |
298: | } |
299: | $fn = $mode === 'add' ? 'add' : 'remove'; |
300: | |
301: | if ( !($this->permission_level & PRIVILEGE_SITE) ) { |
302: | if (!$this->ipsetWrapper('whitelist', $ip, $mode)) { |
303: | return false; |
304: | } |
305: | |
306: | if (Delegated::IPSET_NAME !== 'whitelist') { |
307: | |
308: | return true; |
309: | } |
310: | |
311: | |
312: | return $fn === 'add' ? Delegated::markDelegated($ip, \Opcenter\SiteConfiguration::RESERVED_SITE) : |
313: | Delegated::releaseDelegation($ip, \Opcenter\SiteConfiguration::RESERVED_SITE); |
314: | } |
315: | |
316: | if ($this->auth_is_demo()) { |
317: | return error('Demo accounts cannot alter whitelist'); |
318: | } else if (!version_compare(APNSCP_VERSION, '3.1', '>=')) { |
319: | return error('Delegated whitelisting supported on v8+ platforms'); |
320: | } else if (false !== strpos($ip, '/')) { |
321: | return error('Delegated whitelisting cannot accept ranges'); |
322: | } else if (!$this->addressValid($ip)) { |
323: | return false; |
324: | } |
325: | |
326: | return Delegated::instantiateContexted($this->getAuthContext())->$fn($ip); |
327: | |
328: | } |
329: | |
330: | |
331: | |
332: | |
333: | |
334: | |
335: | public function can_delegate(): bool |
336: | { |
337: | return Delegated::instantiateContexted($this->getAuthContext())->permitted(); |
338: | } |
339: | |
340: | |
341: | |
342: | |
343: | |
344: | |
345: | public function max_delegations(): ?int |
346: | { |
347: | if (!$this->can_delegate()) { |
348: | return 0; |
349: | } |
350: | $val = $this->getServiceValue('rampart', 'max', RAMPART_DELEGATED_WHITELIST); |
351: | if ($val === DefaultNullable::NULLABLE_MARKER) { |
352: | |
353: | |
354: | |
355: | |
356: | $val = RAMPART_DELEGATED_WHITELIST; |
357: | } |
358: | return $val; |
359: | } |
360: | |
361: | |
362: | |
363: | |
364: | |
365: | |
366: | public function enabled(): bool |
367: | { |
368: | return (bool)$this->getConfig('rampart', 'enabled', true) && |
369: | (!($this->permission_level & PRIVILEGE_USER) || RAMPART_USER_DISCOVERY); |
370: | } |
371: | |
372: | |
373: | |
374: | |
375: | |
376: | |
377: | public function get_delegated_list(): array |
378: | { |
379: | if (!version_compare(APNSCP_VERSION, '3.1', '>=')) { |
380: | return []; |
381: | } |
382: | |
383: | return Delegated::instantiateContexted($this->getAuthContext())->get(); |
384: | } |
385: | |
386: | |
387: | |
388: | |
389: | |
390: | |
391: | |
392: | |
393: | |
394: | private function ipsetWrapper(string $set, string $address, string $mode): bool { |
395: | if (!$this->addressValid($address)) { |
396: | return false; |
397: | } |
398: | |
399: | if (static::FAIL2BAN_DRIVER !== 'ipset') { |
400: | return error('ipset is not configured as Rampart driver'); |
401: | } |
402: | |
403: | if (($set === 'ignorelist' || $set === 'whitelist' || $set === 'blacklist') && Ip6::family($address)) { |
404: | $set .= '6'; |
405: | } |
406: | if ($mode === 'delete') { |
407: | $mode = 'remove'; |
408: | } |
409: | |
410: | if ($mode !== 'add' && $mode !== 'remove') { |
411: | return error("Unknown ipset wrapper mode `%s'", $mode); |
412: | } |
413: | |
414: | return Ipset::$mode($set, $address); |
415: | } |
416: | |
417: | |
418: | |
419: | |
420: | |
421: | |
422: | |
423: | public function get_plist(string $list) |
424: | { |
425: | if (!IS_CLI) { |
426: | return $this->query('rampart_get_plist', $list); |
427: | } |
428: | $valid = static::PLIST_NAMES; |
429: | if ($this->permission_level & PRIVILEGE_ADMIN) { |
430: | $valid = array_merge($valid, ['whitelist', 'whitelist6']); |
431: | } |
432: | if (!\in_array($list, $valid, true)) { |
433: | return error("Unknown permanent list `%s'", $list); |
434: | } |
435: | return array_column(Ipset::getSetMembers($list), 'host'); |
436: | } |
437: | |
438: | |
439: | |
440: | |
441: | |
442: | |
443: | |
444: | public function get_jail_entries(?string $jail) |
445: | { |
446: | if (!IS_CLI) { |
447: | return $this->query('rampart_get_jail_entries', $jail); |
448: | } |
449: | $list = static::FAIL2BAN_IPT_PREFIX . $jail; |
450: | if (!$jail) { |
451: | $items = (array)(new Firewall())->getEntriesFromChain(); |
452: | return json_decode(json_encode($items), true); |
453: | } |
454: | if (!\in_array($jail, $this->get_jails(), true)) { |
455: | return error("Unknown jail `%s'", $jail); |
456: | } |
457: | |
458: | return array_column((array)(new Firewall())->getEntriesFromChain($list), 'host'); |
459: | } |
460: | |
461: | |
462: | |
463: | |
464: | |
465: | |
466: | |
467: | public function flush(string $jail = null): bool |
468: | { |
469: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
470: | return error("Firewall may not be modified in demo mode"); |
471: | } |
472: | |
473: | if (!IS_CLI) { |
474: | return $this->query('rampart_flush', $jail); |
475: | } |
476: | |
477: | if ($jail && !\in_array($jail, $this->get_jails(), true)) { |
478: | return error("Unknown jail `%s'", $jail); |
479: | } else if (!$jail) { |
480: | $ret = \Util_Process::exec('fail2ban-client unban --all'); |
481: | return $ret['success']; |
482: | } |
483: | |
484: | foreach ((array)$jail as $j) { |
485: | $ret = \Util_Process::exec('fail2ban-client reload --unban %s', $j); |
486: | if (!$ret['success']) { |
487: | warn("Failed to empty jail `%s'", $jail); |
488: | } |
489: | } |
490: | return true; |
491: | } |
492: | |
493: | |
494: | |
495: | |
496: | |
497: | |
498: | |
499: | |
500: | public function blacklist(string $ip, string $mode = 'add'): bool |
501: | { |
502: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
503: | return error("Firewall may not be modified in demo mode"); |
504: | } |
505: | |
506: | if (!IS_CLI) { |
507: | return $this->query('rampart_blacklist', $ip, $mode); |
508: | } |
509: | |
510: | return $this->ipsetWrapper('blacklist', $ip, $mode); |
511: | |
512: | } |
513: | |
514: | |
515: | |
516: | |
517: | |
518: | |
519: | |
520: | private function addressValid(string $address): bool |
521: | { |
522: | $class = ''; |
523: | if (!IpCommon::supported($address, $class)) { |
524: | return error('Requested address family %s disabled on server', $class); |
525: | } |
526: | if (!IpCommon::valid($address)) { |
527: | return error("Address `%s' is invalid %s", |
528: | $address, $class |
529: | ); |
530: | } |
531: | |
532: | return true; |
533: | } |
534: | |
535: | |
536: | |
537: | |
538: | |
539: | |
540: | public function get_jails(): array |
541: | { |
542: | static $jails; |
543: | if ($jails === null) { |
544: | $cache = \Cache_Global::spawn($this->getAuthContext()); |
545: | if (false === ($jails = $cache->get('rampart.jails'))) { |
546: | if (!IS_CLI) { |
547: | return $this->query('rampart_get_jails'); |
548: | } |
549: | $jails = Fail2ban::getJails(); |
550: | $cache->set('rampart.jails', $jails, 1800); |
551: | } |
552: | } |
553: | |
554: | return $jails ?? []; |
555: | } |
556: | |
557: | |
558: | |
559: | |
560: | |
561: | |
562: | |
563: | |
564: | public function unban(string $ip = null, string $jail = null): bool |
565: | { |
566: | if (DEMO_ADMIN_LOCK && posix_getuid()) { |
567: | return error("Firewall may not be modified in demo mode"); |
568: | } |
569: | |
570: | if (!IS_CLI) { |
571: | return $this->query('rampart_unban', $ip, $jail); |
572: | } |
573: | if ($this->auth_is_demo()) { |
574: | return error('cannot unban IP address in demo mode'); |
575: | } |
576: | if (false === ($ip = $this->checkInput($ip, $jail))) { |
577: | return false; |
578: | } |
579: | foreach ($this->getMatches($ip, $jail) as $chain) { |
580: | if (!$jail = $this->chain2Jail($chain)) { |
581: | warn("Address blocked in `%s' but not recognized Rampart jail - cannot unban %s", $chain, $ip); |
582: | continue; |
583: | } |
584: | |
585: | $ret = \Util_Process_Safe::exec('fail2ban-client set %s unbanip %s', $jail, $ip); |
586: | if ($ret['success']) { |
587: | info("Unbanned `%s' from jail `%s'", $ip, $jail); |
588: | } else { |
589: | warn("Failed to unban `%s' from jail `%s'", $ip, $jail); |
590: | } |
591: | } |
592: | |
593: | if (!Delegated::getDelegators($ip) || RAMPART_SPECULATIVE_WHITELIST < 1) { |
594: | return true; |
595: | } |
596: | |
597: | return Delegated::instantiateContexted($this->getAuthContext())->temporary($ip) ?: |
598: | warn("Failed to temporarily whitelist `%s'", $ip); |
599: | } |
600: | |
601: | |
602: | |
603: | |
604: | |
605: | |
606: | |
607: | |
608: | |
609: | public function bans_since(int $begin, int $end = null, array $jails = null): array |
610: | { |
611: | if (!IS_CLI) { |
612: | return $this->query('rampart_bans_since', $begin, $end, $jails); |
613: | } |
614: | if ($begin < 0 || $end && $end < 0) { |
615: | error('Invalid timestamp'); |
616: | return []; |
617: | } else if ($end && $begin > $end) { |
618: | error('Begin TS may not be newer than end TS'); |
619: | return []; |
620: | } |
621: | $fragment = "timeofban >= $begin"; |
622: | if ($end) { |
623: | $fragment .= " AND timeofban < $end"; |
624: | } |
625: | if ($jails) { |
626: | $diff = array_diff($jails, $this->get_jails()); |
627: | if ($diff) { |
628: | error('Invalid jails specified: %s', implode(',', $diff)); |
629: | return []; |
630: | } |
631: | $fragment .= " AND jail IN ('" . implode("','", $jails) . "')"; |
632: | } |
633: | $query = "SELECT count(*) AS count, jail FROM bans WHERE $fragment GROUP BY (jail)"; |
634: | $db = Fail2ban::getDatabaseHandler(); |
635: | $built = []; |
636: | $results = $db->query($query); |
637: | |
638: | if (!$results) { |
639: | return $built; |
640: | } |
641: | foreach ($results->fetchAll(\PDO::FETCH_ASSOC) as $rec) { |
642: | $built[$rec['jail']] = (int)$rec['count']; |
643: | } |
644: | |
645: | return $built; |
646: | } |
647: | |
648: | |
649: | |
650: | |
651: | |
652: | |
653: | |
654: | protected function chain2Jail(string $chain): ?string |
655: | { |
656: | if (isset($this->confMapping[$chain])) { |
657: | return $this->confMapping[$chain]; |
658: | } |
659: | $jails = $this->getJailConfig(); |
660: | $chain = ' ' . $chain . ' '; |
661: | foreach ($jails as $jail => $actions) { |
662: | foreach ($actions as $action) { |
663: | if (false !== strpos($action, $chain)) { |
664: | $this->confMapping[$chain] = $jail; |
665: | |
666: | return $jail; |
667: | } |
668: | } |
669: | } |
670: | |
671: | return null; |
672: | } |
673: | |
674: | protected function getJailConfig(): ?array |
675: | { |
676: | $cache = \Cache_Global::spawn(); |
677: | $key = static::FAIL2BAN_CACHE_KEY . '.jail-config'; |
678: | if (false === ($jails = $cache->get($key))) { |
679: | if (null === ($jails = Fail2ban::map())) { |
680: | warn('error retrieving fail2ban jail configuration'); |
681: | |
682: | return []; |
683: | } |
684: | $cache->set($key, $jails, 86400); |
685: | } |
686: | |
687: | if (is_string($jails)) { |
688: | report($jails); |
689: | } |
690: | |
691: | return $jails; |
692: | } |
693: | |
694: | public function _cron(Cronus $cron) |
695: | { |
696: | if (!TELEMETRY_ENABLED) { |
697: | return; |
698: | } |
699: | |
700: | $rampartMetric = new \Daphnie\Metrics\Rampart(); |
701: | $lastRun = 0; |
702: | $attrCheck = $rampartMetric->mutate('recidive')->metricAsAttribute(); |
703: | if ($this->telemetry_has($attrCheck)) { |
704: | $lastRun = array_get($this->telemetry_get($attrCheck, null), 'ts', 0); |
705: | } |
706: | |
707: | $attrs = \Daphnie\Metrics\Rampart::getAttributeMap(); |
708: | $jails = array_keys($attrs); |
709: | $bans = $this->bans_since($lastRun, time(), $jails); |
710: | |
711: | $collector = new \Daphnie\Collector(PostgreSQL::pdo()); |
712: | foreach ($jails as $jail) { |
713: | $name = $rampartMetric->mutate($jail); |
714: | $collector->add($name->metricAsAttribute(), null, array_get($bans, $jail, 0)); |
715: | } |
716: | } |
717: | |
718: | public function _housekeeping() |
719: | { |
720: | $this->getJailConfig(); |
721: | } |
722: | } |