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 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: * Class Rampart_Module
24: *
25: * Integrates into fail2ban. Provides short-term and long-term blocks
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: // @var array list of permanent lists
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: * Authenticated client IP or $ip is banned
76: *
77: * @param string|null $ip
78: * @param string|null $jail optional jail to check
79: * @return bool
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: * Get services for which IP is banned
95: *
96: * @param string $ip
97: * @return array
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: * Get reason for ban
113: *
114: * @param string|null $ip
115: * @param string|null $jail
116: * @return string|null
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: * Perform permission validation and IP transformation
159: *
160: * @param string|null $ip
161: * @param string|null $jail
162: * @return false|string
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: * Get matching rules where IP is banned
192: *
193: * @param string $ip
194: * @param string|null $jail optional jail to restrict check
195: * @return array jails banned
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: * Disallow an IP address from service
221: *
222: * @param string $ip
223: * @param string $jail
224: * @return bool
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: * Temporarily whitelist an IP
248: *
249: * @param string $ip
250: * @param int $duration
251: * @return bool
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: // fetch whitelist
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: * Whitelist IP acccess
273: *
274: * @param string|null $ip whitelist named or present IP
275: * @param string $mode
276: * @return bool
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: // ignorelist doesn't require delegation locking
308: return true;
309: }
310:
311: // use "site0" for admin
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: * Account supports delegation abilities
332: *
333: * @return bool
334: */
335: public function can_delegate(): bool
336: {
337: return Delegated::instantiateContexted($this->getAuthContext())->permitted();
338: }
339:
340: /**
341: * Get maximum number of delegated entries
342: *
343: * @return int|null
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: * (new ConfigurationContext('rampart', new SiteConfiguration('')))->getValidatorClass('max')->getDefault()
354: * is a little slow, let's bypass this logic and just pull from config.ini
355: */
356: $val = RAMPART_DELEGATED_WHITELIST;
357: }
358: return $val;
359: }
360:
361: /**
362: * Rampart service enabled
363: *
364: * @return bool
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: * Return a list of delegated whitelist entries
374: *
375: * @return array
376: */
377: public function get_delegated_list(): array
378: {
379: if (!version_compare(APNSCP_VERSION, '3.1', '>=')) {
380: return [];
381: }
382: // wrapper for get_plist
383: return Delegated::instantiateContexted($this->getAuthContext())->get();
384: }
385:
386: /**
387: * ipset wrapper
388: *
389: * @param string $set set name
390: * @param string $address ip address or CIDR
391: * @param string $mode
392: * @return bool
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: * Get permanent list entries
419: *
420: * @param string $list "blacklist" or "whitelist"
421: * @return array|bool
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: * Get jail entries
440: *
441: * @param string $jail
442: * @return array|bool array or false on failure
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: * Flush jails
463: *
464: * @param string|null $jail
465: * @return bool
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: * Permanently block access
495: *
496: * @param string $ip
497: * @param string $mode add, remove, or set
498: * @return bool
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: * Specified address is valid address or range
516: *
517: * @param string $address
518: * @return bool
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: * Get active jails
537: *
538: * @return array
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: * Unban an IP address
559: *
560: * @param string|null $ip
561: * @param string|null $jail optional jail to remove
562: * @return bool
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: * Get ban counts for each jail
603: *
604: * @param int $begin begin ts inclusive
605: * @param int|null $end end ts exclusive
606: * @param array|null $jails restrict to jails
607: * @return array
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: * Convert an iptables rule into a fail2ban jail
650: *
651: * @param string $chain iptables chain
652: * @return null|string
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: }