1: <?php declare(strict_types=1);
2: /**
3: * Copyright (C) Apis Networks, Inc - All Rights Reserved.
4: *
5: * Unauthorized copying of this file, via any medium, is
6: * strictly prohibited without consent. Any dissemination of
7: * material herein is prohibited.
8: *
9: * For licensing inquiries email <licensing@apisnetworks.com>
10: *
11: * Written by Matt Saladna <matt@apisnetworks.com>, April 2018
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: * Class Rampart_Module
25: *
26: * Integrates into fail2ban. Provides short-term and long-term blocks
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: // @var array list of permanent lists
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: * Authenticated client IP or $ip is banned
77: *
78: * @param string|null $ip
79: * @param string|null $jail optional jail to check
80: * @return bool
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: * Get services for which IP is banned
96: *
97: * @param string $ip
98: * @return array
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: * Get reason for ban
114: *
115: * @param string|null $ip
116: * @param string|null $jail
117: * @return string|null
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: * Perform permission validation and IP transformation
160: *
161: * @param string|null $ip
162: * @param string|null $jail
163: * @return false|string
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: * Get matching rules where IP is banned
193: *
194: * @param string $ip
195: * @param string|null $jail optional jail to restrict check
196: * @return array jails banned
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: * Disallow an IP address from service
222: *
223: * @param string $ip
224: * @param string $jail
225: * @return bool
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: * Temporarily whitelist an IP
249: *
250: * @param string $ip
251: * @param int $duration
252: * @return bool
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: // fetch whitelist
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: * Whitelist IP acccess
274: *
275: * @param string|null $ip whitelist named or present IP
276: * @param string $mode
277: * @return bool
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: // ignorelist doesn't require delegation locking
309: return true;
310: }
311:
312: // use "site0" for admin
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: * Account supports delegation abilities
333: *
334: * @return bool
335: */
336: public function can_delegate(): bool
337: {
338: return Delegated::instantiateContexted($this->getAuthContext())->permitted();
339: }
340:
341: /**
342: * Get maximum number of delegated entries
343: *
344: * @return int|null
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: * (new ConfigurationContext('rampart', new SiteConfiguration('')))->getValidatorClass('max')->getDefault()
355: * is a little slow, let's bypass this logic and just pull from config.ini
356: */
357: $val = RAMPART_DELEGATED_WHITELIST;
358: }
359: return $val;
360: }
361:
362: /**
363: * Rampart service enabled
364: *
365: * @return bool
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: * Return a list of delegated whitelist entries
375: *
376: * @return array
377: */
378: public function get_delegated_list(): array
379: {
380: if (!version_compare(APNSCP_VERSION, '3.1', '>=')) {
381: return [];
382: }
383: // wrapper for get_plist
384: return Delegated::instantiateContexted($this->getAuthContext())->get();
385: }
386:
387: /**
388: * ipset wrapper
389: *
390: * @param string $set set name
391: * @param string $address ip address or CIDR
392: * @param string $mode
393: * @return bool
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: * Get permanent list entries
420: *
421: * @param string $list "blacklist" or "whitelist"
422: * @return array|bool
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: * Get jail entries
441: *
442: * @param string $jail
443: * @return array|bool array or false on failure
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: * Flush jails
464: *
465: * @param string|null $jail
466: * @return bool
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: * Permanently block access
496: *
497: * @param string $ip
498: * @param string $mode add, remove, or set
499: * @return bool
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: * Specified address is valid address or range
517: *
518: * @param string $address
519: * @return bool
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: * Get active jails
538: *
539: * @return array
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: * Unban an IP address
560: *
561: * @param string|null $ip
562: * @param string|null $jail optional jail to remove
563: * @return bool
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: * Get ban counts for each jail
604: *
605: * @param int $begin begin ts inclusive
606: * @param int|null $end end ts exclusive
607: * @param array|null $jails restrict to jails
608: * @return array
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: * Convert an iptables rule into a fail2ban jail
651: *
652: * @param string $chain iptables chain
653: * @return null|string
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: }