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