automatic reban (repeat banning action) after repair/restore sane environment, if already logged ticket causes new failures (part of #980, closes #1680);

introduces banning epoch for actions and tickets (to distinguish or recognize removed set of tickets)
pull/2588/head
sebres 5 years ago
parent 1a9bc1905d
commit f001f8de2a

@ -217,6 +217,7 @@ class ActionBase(object):
"start", "start",
"stop", "stop",
"ban", "ban",
"reban",
"unban", "unban",
) )
for method in required: for method in required:
@ -250,6 +251,17 @@ class ActionBase(object):
""" """
pass pass
def reban(self, aInfo): # pragma: no cover - abstract
"""Executed when a ban occurs.
Parameters
----------
aInfo : dict
Dictionary which includes information in relation to
the ban.
"""
return self.ban(aInfo)
def unban(self, aInfo): # pragma: no cover - abstract def unban(self, aInfo): # pragma: no cover - abstract
"""Executed when a ban expires. """Executed when a ban expires.
@ -281,6 +293,7 @@ class CommandAction(ActionBase):
---------- ----------
actionban actionban
actioncheck actioncheck
actionreban
actionreload actionreload
actionrepair actionrepair
actionstart actionstart
@ -301,6 +314,7 @@ class CommandAction(ActionBase):
self.actionstart = '' self.actionstart = ''
## Command executed when ticket gets banned. ## Command executed when ticket gets banned.
self.actionban = '' self.actionban = ''
self.actionreban = ''
## Command executed when ticket gets removed. ## Command executed when ticket gets removed.
self.actionunban = '' self.actionunban = ''
## Command executed in order to check requirements. ## Command executed in order to check requirements.
@ -504,8 +518,8 @@ class CommandAction(ActionBase):
ret = self._executeOperation('<actionstart>', 'starting', family=family, afterExec=_started) ret = self._executeOperation('<actionstart>', 'starting', family=family, afterExec=_started)
return ret return ret
def ban(self, aInfo): def ban(self, aInfo, cmd='<actionban>'):
"""Executes the "actionban" command. """Executes the given command ("actionban" or "actionreban").
Replaces the tags in the action command with actions properties Replaces the tags in the action command with actions properties
and ban information, and executes the resulting command. and ban information, and executes the resulting command.
@ -522,7 +536,7 @@ class CommandAction(ActionBase):
if not self.__started.get(family): if not self.__started.get(family):
self._start(family, forceStart=True) self._start(family, forceStart=True)
# ban: # ban:
if not self._processCmd('<actionban>', aInfo): if not self._processCmd(cmd, aInfo):
raise RuntimeError("Error banning %(ip)s" % aInfo) raise RuntimeError("Error banning %(ip)s" % aInfo)
self.__started[family] = self.__started.get(family, 0) | 3; # started and contains items self.__started[family] = self.__started.get(family, 0) | 3; # started and contains items
@ -543,6 +557,21 @@ class CommandAction(ActionBase):
if not self._processCmd('<actionunban>', aInfo): if not self._processCmd('<actionunban>', aInfo):
raise RuntimeError("Error unbanning %(ip)s" % aInfo) raise RuntimeError("Error unbanning %(ip)s" % aInfo)
def reban(self, aInfo):
"""Executes the "actionreban" command if available, otherwise simply repeat "actionban".
Replaces the tags in the action command with actions properties
and ban information, and executes the resulting command.
Parameters
----------
aInfo : dict
Dictionary which includes information in relation to
the ban.
"""
# re-ban:
return self.ban(aInfo, '<actionreban>' if self.actionreban else '<actionban>')
def flush(self): def flush(self):
"""Executes the "actionflush" command. """Executes the "actionflush" command.
@ -812,6 +841,17 @@ class CommandAction(ActionBase):
realCmd = Utils.buildShellCmd(realCmd, varsDict) realCmd = Utils.buildShellCmd(realCmd, varsDict)
return realCmd return realCmd
@property
def banEpoch(self):
return getattr(self, '_banEpoch', 0)
def invalidateBanEpoch(self):
"""Increments ban epoch of jail and this action, so already banned tickets would cause
a re-ban for all tickets with previous epoch."""
if self._jail is not None:
self._banEpoch = self._jail.actions.banEpoch = self._jail.actions.banEpoch + 1
else:
self._banEpoch = self.banEpoch + 1
def _invariantCheck(self, family=None, beforeRepair=None, forceStart=True): def _invariantCheck(self, family=None, beforeRepair=None, forceStart=True):
"""Executes a substituted `actioncheck` command. """Executes a substituted `actioncheck` command.
""" """
@ -826,6 +866,8 @@ class CommandAction(ActionBase):
return -1 return -1
self._logSys.error( self._logSys.error(
"Invariant check failed. Trying to restore a sane environment") "Invariant check failed. Trying to restore a sane environment")
# increment ban epoch of jail and this action (allows re-ban on already banned):
self.invalidateBanEpoch()
# try to find repair command, if exists - exec it: # try to find repair command, if exists - exec it:
repairCmd = self._getOperation('<actionrepair>', family) repairCmd = self._getOperation('<actionrepair>', family)
if repairCmd: if repairCmd:

@ -81,6 +81,8 @@ class Actions(JailThread, Mapping):
self._actions = OrderedDict() self._actions = OrderedDict()
## The ban manager. ## The ban manager.
self.__banManager = BanManager() self.__banManager = BanManager()
self.banEpoch = 0
self.__lastConsistencyCheckTM = 0
## Precedence of ban (over unban), so max number of tickets banned (to call an unban check): ## Precedence of ban (over unban), so max number of tickets banned (to call an unban check):
self.banPrecedence = 10 self.banPrecedence = 10
## Max count of outdated tickets to unban per each __checkUnBan operation: ## Max count of outdated tickets to unban per each __checkUnBan operation:
@ -439,6 +441,7 @@ class Actions(JailThread, Mapping):
cnt = 0 cnt = 0
if not tickets: if not tickets:
tickets = self.__getFailTickets(self.banPrecedence) tickets = self.__getFailTickets(self.banPrecedence)
rebanacts = None
for ticket in tickets: for ticket in tickets:
bTicket = BanManager.createBanTicket(ticket) bTicket = BanManager.createBanTicket(ticket)
ip = bTicket.getIP() ip = bTicket.getIP()
@ -461,6 +464,8 @@ class Actions(JailThread, Mapping):
exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) exc_info=logSys.getEffectiveLevel()<=logging.DEBUG)
# after all actions are processed set banned flag: # after all actions are processed set banned flag:
bTicket.banned = True bTicket.banned = True
if self.banEpoch: # be sure tickets always have the same ban epoch (default 0):
bTicket.banEpoch = self.banEpoch
else: else:
bTicket = reason['ticket'] bTicket = reason['ticket']
# if already banned (otherwise still process some action) # if already banned (otherwise still process some action)
@ -475,11 +480,61 @@ class Actions(JailThread, Mapping):
else logging.NOTICE if diftm < 60 \ else logging.NOTICE if diftm < 60 \
else logging.WARNING else logging.WARNING
logSys.log(ll, "[%s] %s already banned", self._jail.name, ip) logSys.log(ll, "[%s] %s already banned", self._jail.name, ip)
# if long time after ban - do consistency check (something is wrong here):
if bTicket.banEpoch == self.banEpoch and diftm > 3:
# avoid too often checks:
if not rebanacts and MyTime.time() > self.__lastConsistencyCheckTM + 3:
for action in self._actions.itervalues():
action.consistencyCheck()
self.__lastConsistencyCheckTM = MyTime.time()
# check epoch in order to reban it:
if bTicket.banEpoch < self.banEpoch:
if not rebanacts: rebanacts = dict(
(name, action) for name, action in self._actions.iteritems()
if action.banEpoch > bTicket.banEpoch)
cnt += self.__reBan(bTicket, actions=rebanacts)
else:
# pragma: no cover - unexpected: ticket is not banned for some reasons - reban using all actions:
cnt += self.__reBan(bTicket)
if cnt: if cnt:
logSys.debug("Banned %s / %s, %s ticket(s) in %r", cnt, logSys.debug("Banned %s / %s, %s ticket(s) in %r", cnt,
self.__banManager.getBanTotal(), self.__banManager.size(), self._jail.name) self.__banManager.getBanTotal(), self.__banManager.size(), self._jail.name)
return cnt return cnt
def __reBan(self, ticket, actions=None, log=True):
"""Repeat bans for the ticket.
Executes the actions in order to reban the host given in the
ticket.
Parameters
----------
ticket : Ticket
Ticket to reban
"""
actions = actions or self._actions
ip = ticket.getIP()
aInfo = self.__getActionInfo(ticket)
if log:
logSys.notice("[%s] Reban %s%s", self._jail.name, aInfo["ip"], (', action %r' % actions.keys()[0] if len(actions) == 1 else ''))
for name, action in actions.iteritems():
try:
logSys.debug("[%s] action %r: reban %s", self._jail.name, name, ip)
if not aInfo.immutable: aInfo.reset()
action.reban(aInfo)
except Exception as e:
logSys.error(
"Failed to execute reban jail '%s' action '%s' "
"info '%r': %s",
self._jail.name, name, aInfo, e,
exc_info=logSys.getEffectiveLevel()<=logging.DEBUG)
return 0
# after all actions are processed set banned flag:
ticket.banned = True
if self.banEpoch: # be sure tickets always have the same ban epoch (default 0):
ticket.banEpoch = self.banEpoch
return 1
def __checkUnBan(self, maxCount=None): def __checkUnBan(self, maxCount=None):
"""Check for IP address to unban. """Check for IP address to unban.
@ -522,7 +577,7 @@ class Actions(JailThread, Mapping):
logSys.error("Failed to flush bans in jail '%s' action '%s': %s", logSys.error("Failed to flush bans in jail '%s' action '%s': %s",
self._jail.name, name, e, self._jail.name, name, e,
exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) exc_info=logSys.getEffectiveLevel()<=logging.DEBUG)
logSys.info("No flush occured, do consistency check") logSys.info("No flush occurred, do consistency check")
if hasattr(action, 'consistencyCheck'): if hasattr(action, 'consistencyCheck'):
def _beforeRepair(): def _beforeRepair():
if stop and not getattr(action, 'actionrepair_on_unban', None): # don't need repair on stop if stop and not getattr(action, 'actionrepair_on_unban', None): # don't need repair on stop
@ -569,8 +624,6 @@ class Actions(JailThread, Mapping):
logSys.notice("[%s] Unban %s", self._jail.name, aInfo["ip"]) logSys.notice("[%s] Unban %s", self._jail.name, aInfo["ip"])
for name, action in unbactions.iteritems(): for name, action in unbactions.iteritems():
try: try:
if ticket.restored and getattr(action, 'norestored', False):
continue
logSys.debug("[%s] action %r: unban %s", self._jail.name, name, ip) logSys.debug("[%s] action %r: unban %s", self._jail.name, name, ip)
if not aInfo.immutable: aInfo.reset() if not aInfo.immutable: aInfo.reset()
action.unban(aInfo) action.unban(aInfo)

@ -206,6 +206,13 @@ class Ticket(object):
# return single value of data: # return single value of data:
return self._data.get(key, default) return self._data.get(key, default)
@property
def banEpoch(self):
return getattr(self, '_banEpoch', 0)
@banEpoch.setter
def banEpoch(self, value):
self._banEpoch = value
class FailTicket(Ticket): class FailTicket(Ticket):

@ -28,11 +28,10 @@ import time
import os import os
import tempfile import tempfile
from ..server.actions import Actions
from ..server.ticket import FailTicket from ..server.ticket import FailTicket
from ..server.utils import Utils from ..server.utils import Utils
from .dummyjail import DummyJail from .dummyjail import DummyJail
from .utils import LogCaptureTestCase, with_alt_time, MyTime from .utils import LogCaptureTestCase, with_alt_time, with_tmpdir, MyTime
TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), "files") TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), "files")
@ -43,7 +42,7 @@ class ExecuteActions(LogCaptureTestCase):
"""Call before every test case.""" """Call before every test case."""
super(ExecuteActions, self).setUp() super(ExecuteActions, self).setUp()
self.__jail = DummyJail() self.__jail = DummyJail()
self.__actions = Actions(self.__jail) self.__actions = self.__jail.actions
def tearDown(self): def tearDown(self):
super(ExecuteActions, self).tearDown() super(ExecuteActions, self).tearDown()
@ -52,8 +51,8 @@ class ExecuteActions(LogCaptureTestCase):
self.__actions.add('ip') self.__actions.add('ip')
act = self.__actions['ip'] act = self.__actions['ip']
act.actionstart = 'echo ip start'+o.get('start', '') act.actionstart = 'echo ip start'+o.get('start', '')
act.actionban = 'echo ip ban <ip>' act.actionban = 'echo ip ban <ip>'+o.get('ban', '')
act.actionunban = 'echo ip unban <ip>' act.actionunban = 'echo ip unban <ip>'+o.get('unban', '')
act.actioncheck = 'echo ip check'+o.get('check', '') act.actioncheck = 'echo ip check'+o.get('check', '')
act.actionflush = 'echo ip flush'+o.get('flush', '') act.actionflush = 'echo ip flush'+o.get('flush', '')
act.actionstop = 'echo ip stop'+o.get('stop', '') act.actionstop = 'echo ip stop'+o.get('stop', '')
@ -239,7 +238,7 @@ class ExecuteActions(LogCaptureTestCase):
"stdout: %r" % 'ip flush inet4', "stdout: %r" % 'ip flush inet4',
"stdout: %r" % 'ip flush inet6', "stdout: %r" % 'ip flush inet6',
'Failed to flush bans', 'Failed to flush bans',
'No flush occured, do consistency check', 'No flush occurred, do consistency check',
'Invariant check failed. Trying to restore a sane environment', 'Invariant check failed. Trying to restore a sane environment',
"stdout: %r" % 'ip stop', # same for both families "stdout: %r" % 'ip stop', # same for both families
'Failed to flush bans', 'Failed to flush bans',
@ -259,7 +258,7 @@ class ExecuteActions(LogCaptureTestCase):
self.pruneLog('[test-phase 3] failed flush in consistent env') self.pruneLog('[test-phase 3] failed flush in consistent env')
self.__actions._Actions__flushBan() self.__actions._Actions__flushBan()
self.assertLogged('Failed to flush bans', self.assertLogged('Failed to flush bans',
'No flush occured, do consistency check', 'No flush occurred, do consistency check',
"stdout: %r" % 'ip flush inet6', "stdout: %r" % 'ip flush inet6',
"stdout: %r" % 'ip check inet6', "stdout: %r" % 'ip check inet6',
all=True, wait=True) all=True, wait=True)
@ -341,7 +340,7 @@ class ExecuteActions(LogCaptureTestCase):
"stdout: %r" % 'ip flush inet4', "stdout: %r" % 'ip flush inet4',
"stdout: %r" % 'ip flush inet6', "stdout: %r" % 'ip flush inet6',
'Failed to flush bans', 'Failed to flush bans',
'No flush occured, do consistency check', 'No flush occurred, do consistency check',
'Invariant check failed. Trying to restore a sane environment', 'Invariant check failed. Trying to restore a sane environment',
"stdout: %r" % 'ip stop inet6', "stdout: %r" % 'ip stop inet6',
'Failed to flush bans in jail', 'Failed to flush bans in jail',
@ -368,7 +367,7 @@ class ExecuteActions(LogCaptureTestCase):
act['actioncheck?family=inet6'] = act.actioncheck act['actioncheck?family=inet6'] = act.actioncheck
self.__actions._Actions__flushBan() self.__actions._Actions__flushBan()
self.assertLogged('Failed to flush bans', self.assertLogged('Failed to flush bans',
'No flush occured, do consistency check', 'No flush occurred, do consistency check',
"stdout: %r" % 'ip flush inet6', "stdout: %r" % 'ip flush inet6',
"stdout: %r" % 'ip check inet6', "stdout: %r" % 'ip check inet6',
all=True, wait=True) all=True, wait=True)
@ -396,3 +395,103 @@ class ExecuteActions(LogCaptureTestCase):
"stdout: %r" % 'ip flush inet4', "stdout: %r" % 'ip flush inet4',
'Unban tickets each individualy', 'Unban tickets each individualy',
all=True) all=True)
@with_alt_time
@with_tmpdir
def testActionsRebanBrokenAfterRepair(self, tmp):
act = self.defaultAction({
'start':' <family>; touch "<FN>"',
'check':' <family>; test -f "<FN>"',
'flush':' <family>; echo -n "" > "<FN>"',
'stop': ' <family>; rm -f "<FN>"',
'ban': ' <family>; echo "<ip> <family>" >> "<FN>"',
})
act['FN'] = tmp+'/<family>'
act.actionstart_on_demand = True
act.actionrepair = 'echo ip repair <family>; touch "<FN>"'
act.actionreban = 'echo ip reban <ip> <family>; echo "<ip> <family> -- rebanned" >> "<FN>"'
self.pruneLog('[test-phase 0] initial ban')
self.assertEqual(self.__actions.addBannedIP(['192.0.2.1', '2001:db8::1']), 2)
self.assertLogged('Ban 192.0.2.1', 'Ban 2001:db8::1',
"stdout: %r" % 'ip start inet4',
"stdout: %r" % 'ip ban 192.0.2.1 inet4',
"stdout: %r" % 'ip start inet6',
"stdout: %r" % 'ip ban 2001:db8::1 inet6',
all=True)
self.pruneLog('[test-phase 1] check ban')
self.dumpFile(tmp+'/inet4')
self.assertLogged('192.0.2.1 inet4')
self.assertNotLogged('2001:db8::1 inet6')
self.pruneLog()
self.dumpFile(tmp+'/inet6')
self.assertLogged('2001:db8::1 inet6')
self.assertNotLogged('192.0.2.1 inet4')
# simulate 3 seconds past:
MyTime.setTime(MyTime.time() + 4)
# already banned produces events:
self.pruneLog('[test-phase 2] check already banned')
self.assertEqual(self.__actions.addBannedIP(['192.0.2.1', '2001:db8::1', '2001:db8::2']), 1)
self.assertLogged(
'192.0.2.1 already banned', '2001:db8::1 already banned', 'Ban 2001:db8::2',
"stdout: %r" % 'ip check inet4', # both checks occurred
"stdout: %r" % 'ip check inet6',
all=True)
self.dumpFile(tmp+'/inet4')
self.dumpFile(tmp+'/inet6')
# no reban should occur:
self.assertNotLogged('Reban 192.0.2.1', 'Reban 2001:db8::1',
"stdout: %r" % 'ip ban 192.0.2.1 inet4',
"stdout: %r" % 'ip reban 192.0.2.1 inet4',
"stdout: %r" % 'ip ban 2001:db8::1 inet6',
"stdout: %r" % 'ip reban 2001:db8::1 inet6',
'192.0.2.1 inet4 -- repaired',
'2001:db8::1 inet6 -- repaired',
all=True)
# simulate 3 seconds past:
MyTime.setTime(MyTime.time() + 4)
# break env (remove both files, so check would fail):
os.remove(tmp+'/inet4')
os.remove(tmp+'/inet6')
# test again already banned (it shall cause reban now):
self.pruneLog('[test-phase 3a] check reban after sane env repaired')
self.assertEqual(self.__actions.addBannedIP(['192.0.2.1', '2001:db8::1']), 2)
self.assertLogged(
"Invariant check failed. Trying to restore a sane environment",
"stdout: %r" % 'ip repair inet4', # both repairs occurred
"stdout: %r" % 'ip repair inet6',
"Reban 192.0.2.1, action 'ip'", "Reban 2001:db8::1, action 'ip'", # both rebans also
"stdout: %r" % 'ip reban 192.0.2.1 inet4',
"stdout: %r" % 'ip reban 2001:db8::1 inet6',
all=True)
# now last IP (2001:db8::2) - no repair, but still old epoch of ticket, so it gets rebanned:
self.pruneLog('[test-phase 3a] check reban by epoch mismatch (without repair)')
self.assertEqual(self.__actions.addBannedIP('2001:db8::2'), 1)
self.assertLogged(
"Reban 2001:db8::2, action 'ip'",
"stdout: %r" % 'ip reban 2001:db8::2 inet6',
all=True)
self.assertNotLogged(
"Invariant check failed. Trying to restore a sane environment",
"stdout: %r" % 'ip repair inet4', # both repairs occurred
"stdout: %r" % 'ip repair inet6',
"Reban 192.0.2.1, action 'ip'", "Reban 2001:db8::1, action 'ip'", # both rebans also
"stdout: %r" % 'ip reban 192.0.2.1 inet4',
"stdout: %r" % 'ip reban 2001:db8::1 inet6',
all=True)
# and bans present in files:
self.pruneLog('[test-phase 4] check reban')
self.dumpFile(tmp+'/inet4')
self.assertLogged('192.0.2.1 inet4 -- rebanned')
self.assertNotLogged('2001:db8::1 inet6 -- rebanned')
self.pruneLog()
self.dumpFile(tmp+'/inet6')
self.assertLogged(
'2001:db8::1 inet6 -- rebanned',
'2001:db8::2 inet6 -- rebanned', all=True)
self.assertNotLogged('192.0.2.1 inet4 -- rebanned')

@ -113,16 +113,7 @@ fail2banclient.input_command = _test_input_command
fail2bancmdline.PRODUCTION = \ fail2bancmdline.PRODUCTION = \
fail2banserver.PRODUCTION = False fail2banserver.PRODUCTION = False
_out_file = LogCaptureTestCase.dumpFile
def _out_file(fn, handle=logSys.debug):
"""Helper which outputs content of the file at HEAVYDEBUG loglevels"""
if (handle != logSys.debug or logSys.getEffectiveLevel() <= logging.DEBUG):
handle('---- ' + fn + ' ----')
for line in fileinput.input(fn):
line = line.rstrip('\n')
handle(line)
handle('-'*30)
def _write_file(fn, mode, *lines): def _write_file(fn, mode, *lines):
f = open(fn, mode) f = open(fn, mode)

@ -752,7 +752,7 @@ class Transmitter(TransmitterBase):
self.assertSortedEqual( self.assertSortedEqual(
self.transm.proceed(["get", self.jailName, "actionmethods", self.transm.proceed(["get", self.jailName, "actionmethods",
action])[1], action])[1],
['ban', 'start', 'stop', 'testmethod', 'unban']) ['ban', 'reban', 'start', 'stop', 'testmethod', 'unban'])
self.assertEqual( self.assertEqual(
self.transm.proceed(["set", self.jailName, "action", action, self.transm.proceed(["set", self.jailName, "action", action,
"testmethod", '{"text": "world!"}']), "testmethod", '{"text": "world!"}']),

@ -22,6 +22,7 @@ __author__ = "Yaroslav Halchenko"
__copyright__ = "Copyright (c) 2013 Yaroslav Halchenko" __copyright__ = "Copyright (c) 2013 Yaroslav Halchenko"
__license__ = "GPL" __license__ = "GPL"
import fileinput
import itertools import itertools
import logging import logging
import optparse import optparse
@ -741,11 +742,8 @@ class LogCaptureTestCase(unittest.TestCase):
self._dirty |= 2 # records changed self._dirty |= 2 # records changed
def setUp(self): def setUp(self):
# For extended testing of what gets output into logging # For extended testing of what gets output into logging
# system, we will redirect it to a string # system, we will redirect it to a string
logSys = getLogger("fail2ban")
# Keep old settings # Keep old settings
self._old_level = logSys.level self._old_level = logSys.level
self._old_handlers = logSys.handlers self._old_handlers = logSys.handlers
@ -762,7 +760,6 @@ class LogCaptureTestCase(unittest.TestCase):
"""Call after every test case.""" """Call after every test case."""
# print "O: >>%s<<" % self._log.getvalue() # print "O: >>%s<<" % self._log.getvalue()
self.pruneLog() self.pruneLog()
logSys = getLogger("fail2ban")
logSys.handlers = self._old_handlers logSys.handlers = self._old_handlers
logSys.level = self._old_level logSys.level = self._old_level
super(LogCaptureTestCase, self).tearDown() super(LogCaptureTestCase, self).tearDown()
@ -845,5 +842,15 @@ class LogCaptureTestCase(unittest.TestCase):
def getLog(self): def getLog(self):
return self._log.getvalue() return self._log.getvalue()
@staticmethod
def dumpFile(fn, handle=logSys.debug):
"""Helper which outputs content of the file at HEAVYDEBUG loglevels"""
if (handle != logSys.debug or logSys.getEffectiveLevel() <= logging.DEBUG):
handle('---- ' + fn + ' ----')
for line in fileinput.input(fn):
line = line.rstrip('\n')
handle(line)
handle('-'*30)
pid_exists = Utils.pid_exists pid_exists = Utils.pid_exists

Loading…
Cancel
Save