Merge pull request #2351 from sebres/0.10-multi-ban-unban-in-jail

fail2ban-client: multi ban/unban and attempt for set jail
pull/2353/head
Sergey G. Brester 2019-02-21 15:42:00 +01:00 committed by GitHub
commit 487e19420e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 176 additions and 89 deletions

View File

@ -53,6 +53,14 @@ ver. 0.10.5-dev-1 (20??/??/??) - development edition
### Enhancements ### Enhancements
* jail-reader extended (amend to gh-1622): actions support multi-line options now (interpolations * jail-reader extended (amend to gh-1622): actions support multi-line options now (interpolations
containing new-line); containing new-line);
* fail2ban-client: extended to ban/unban multiple tickets (see gh-2351, gh-2349);
Syntax:
- `fail2ban-client set <jain> banip <ip1> ... <ipN>`
- `fail2ban-client set <jain> unbanip [--report-absent] <ip1> ... <ipN>`
* fail2ban-client: extended with new feature which allows to inform fail2ban about single or multiple
attempts (failure) for IP (resp. failure-ID), see gh-2351;
Syntax:
- `fail2ban-client set <jail> attempt <ip> [<failure-message1> ... <failure-messageN>]`
ver. 0.10.4 (2018/10/04) - ten-four-on-due-date-ten-four ver. 0.10.4 (2018/10/04) - ten-four-on-due-date-ten-four

View File

@ -246,8 +246,7 @@ class JailReader(ConfigReader):
elif opt == "backend": elif opt == "backend":
backend = value backend = value
elif opt == "ignoreip": elif opt == "ignoreip":
for ip in splitwords(value): stream.append(["set", self.__name, "addignoreip"] + splitwords(value))
stream.append(["set", self.__name, "addignoreip", ip])
elif opt in ("failregex", "ignoreregex"): elif opt in ("failregex", "ignoreregex"):
multi = [] multi = []
for regex in value.split('\n'): for regex in value.split('\n'):

View File

@ -99,8 +99,9 @@ protocol = [
["set <JAIL> bantime <TIME>", "sets the number of seconds <TIME> a host will be banned for <JAIL>"], ["set <JAIL> bantime <TIME>", "sets the number of seconds <TIME> a host will be banned for <JAIL>"],
["set <JAIL> datepattern <PATTERN>", "sets the <PATTERN> used to match date/times for <JAIL>"], ["set <JAIL> datepattern <PATTERN>", "sets the <PATTERN> used to match date/times for <JAIL>"],
["set <JAIL> usedns <VALUE>", "sets the usedns mode for <JAIL>"], ["set <JAIL> usedns <VALUE>", "sets the usedns mode for <JAIL>"],
["set <JAIL> banip <IP>", "manually Ban <IP> for <JAIL>"], ["set <JAIL> attempt <IP> [<failure1> ... <failureN>]", "manually notify about <IP> failure"],
["set <JAIL> unbanip <IP>", "manually Unban <IP> in <JAIL>"], ["set <JAIL> banip <IP> ... <IP>", "manually Ban <IP> for <JAIL>"],
["set <JAIL> unbanip [--report-absent] <IP> ... <IP>", "manually Unban <IP> in <JAIL>"],
["set <JAIL> maxretry <RETRY>", "sets the number of failures <RETRY> before banning the host for <JAIL>"], ["set <JAIL> maxretry <RETRY>", "sets the number of failures <RETRY> before banning the host for <JAIL>"],
["set <JAIL> maxlines <LINES>", "sets the number of <LINES> to buffer for regex search for <JAIL>"], ["set <JAIL> maxlines <LINES>", "sets the number of <LINES> to buffer for regex search for <JAIL>"],
["set <JAIL> addaction <ACT>[ <PYTHONFILE> <JSONKWARGS>]", "adds a new action named <ACT> for <JAIL>. Optionally for a Python based action, a <PYTHONFILE> and <JSONKWARGS> can be specified, else will be a Command Action"], ["set <JAIL> addaction <ACT>[ <PYTHONFILE> <JSONKWARGS>]", "adds a new action named <ACT> for <JAIL>. Optionally for a Python based action, a <PYTHONFILE> and <JSONKWARGS> can be specified, else will be a Command Action"],

View File

@ -34,7 +34,8 @@ try:
except ImportError: except ImportError:
OrderedDict = dict OrderedDict = dict
from .banmanager import BanManager from .banmanager import BanManager, BanTicket
from .ipdns import IPAddr
from .jailthread import JailThread from .jailthread import JailThread
from .action import ActionBase, CommandAction, CallingMap from .action import ActionBase, CommandAction, CallingMap
from .mytime import MyTime from .mytime import MyTime
@ -203,6 +204,19 @@ class Actions(JailThread, Mapping):
def getBanTime(self): def getBanTime(self):
return self.__banManager.getBanTime() return self.__banManager.getBanTime()
def addBannedIP(self, ip):
"""Ban an IP or list of IPs."""
unixTime = MyTime.time()
if isinstance(ip, list):
# Multiple IPs:
tickets = (BanTicket(ip if isinstance(ip, IPAddr) else IPAddr(ip), unixTime) for ip in ip)
else:
# Single IP:
tickets = (BanTicket(ip if isinstance(ip, IPAddr) else IPAddr(ip), unixTime),)
return self.__checkBan(tickets)
def removeBannedIP(self, ip=None, db=True, ifexists=False): def removeBannedIP(self, ip=None, db=True, ifexists=False):
"""Removes banned IP calling actions' unban method """Removes banned IP calling actions' unban method
@ -211,8 +225,8 @@ class Actions(JailThread, Mapping):
Parameters Parameters
---------- ----------
ip : str or IPAddr or None ip : list, str, IPAddr or None
The IP address to unban or all IPs if None The IP address (or multiple IPs as list) to unban or all IPs if None
Raises Raises
------ ------
@ -222,6 +236,19 @@ class Actions(JailThread, Mapping):
# Unban all? # Unban all?
if ip is None: if ip is None:
return self.__flushBan(db) return self.__flushBan(db)
# Multiple IPs:
if isinstance(ip, list):
missed = []
cnt = 0
for i in ip:
try:
cnt += self.removeBannedIP(i, db, ifexists)
except ValueError:
if not ifexists:
missed.append(i)
if missed:
raise ValueError("not banned: %r" % missed)
return cnt
# Single IP: # Single IP:
# Always delete ip from database (also if currently not banned) # Always delete ip from database (also if currently not banned)
if db and self._jail.database is not None: if db and self._jail.database is not None:
@ -232,9 +259,11 @@ class Actions(JailThread, Mapping):
# Unban the IP. # Unban the IP.
self.__unBan(ticket) self.__unBan(ticket)
else: else:
msg = "%s is not banned" % ip
logSys.log(logging.MSG, msg)
if ifexists: if ifexists:
return 0 return 0
raise ValueError("%s is not banned" % ip) raise ValueError(msg)
return 1 return 1
@ -373,11 +402,20 @@ class Actions(JailThread, Mapping):
aInfo = Actions.ActionInfo(ticket, self._jail) aInfo = Actions.ActionInfo(ticket, self._jail)
return aInfo return aInfo
def __getFailTickets(self, count=100):
"""Generator to get maximal count failure tickets from fail-manager."""
cnt = 0
while cnt < count:
ticket = self._jail.getFailTicket()
if not ticket:
break
yield ticket
cnt += 1
def __checkBan(self): def __checkBan(self, tickets=None):
"""Check for IP address to ban. """Check for IP address to ban.
Look in the jail queue for FailTicket. If a ticket is available, If tickets are not specified look in the jail queue for FailTicket. If a ticket is available,
it executes the "ban" command and adds a ticket to the BanManager. it executes the "ban" command and adds a ticket to the BanManager.
Returns Returns
@ -386,10 +424,9 @@ class Actions(JailThread, Mapping):
True if an IP address get banned. True if an IP address get banned.
""" """
cnt = 0 cnt = 0
while cnt < 100: if not tickets:
ticket = self._jail.getFailTicket() tickets = self.__getFailTickets()
if not ticket: for ticket in tickets:
break
bTicket = BanManager.createBanTicket(ticket) bTicket = BanManager.createBanTicket(ticket)
ip = bTicket.getIP() ip = bTicket.getIP()
aInfo = self.__getActionInfo(bTicket) aInfo = self.__getActionInfo(bTicket)

View File

@ -159,7 +159,7 @@ class FailManager:
def toBan(self, fid=None): def toBan(self, fid=None):
with self.__lock: with self.__lock:
for fid in ([fid] if fid != None and fid in self.__failList else self.__failList): for fid in ([fid] if fid is not None and fid in self.__failList else self.__failList):
data = self.__failList[fid] data = self.__failList[fid]
if data.getRetry() >= self.__maxRetry: if data.getRetry() >= self.__maxRetry:
del self.__failList[fid] del self.__failList[fid]

View File

@ -427,23 +427,9 @@ class Filter(JailThread):
) )
else: else:
self.__ignoreCache = None self.__ignoreCache = None
##
# Ban an IP - http://blogs.buanzo.com.ar/2009/04/fail2ban-patch-ban-ip-address-manually.html
# Arturo 'Buanzo' Busleiman <buanzo@buanzo.com.ar>
#
# to enable banip fail2ban-client BAN command
def addBannedIP(self, ip): def performBan(self, ip=None):
if not isinstance(ip, IPAddr): """Performs a ban for IPs (or given ip) that are reached maxretry of the jail."""
ip = IPAddr(ip)
unixTime = MyTime.time()
ticket = FailTicket(ip, unixTime)
if self._inIgnoreIPList(ip, ticket, log_ignore=False):
logSys.warning('Requested to manually ban an ignored IP %s. User knows best. Proceeding to ban it.', ip)
self.failManager.addFailure(ticket, self.failManager.getMaxRetry())
# Perform the banning of the IP now.
try: # pragma: no branch - exception is the only way out try: # pragma: no branch - exception is the only way out
while True: while True:
ticket = self.failManager.toBan(ip) ticket = self.failManager.toBan(ip)
@ -451,7 +437,24 @@ class Filter(JailThread):
except FailManagerEmpty: except FailManagerEmpty:
self.failManager.cleanup(MyTime.time()) self.failManager.cleanup(MyTime.time())
return ip def addAttempt(self, ip, *matches):
"""Generate a failed attempt for ip"""
if not isinstance(ip, IPAddr):
ip = IPAddr(ip)
matches = list(matches) # tuple to list
# Generate the failure attempt for the IP:
unixTime = MyTime.time()
ticket = FailTicket(ip, unixTime, matches=matches)
logSys.info(
"[%s] Attempt %s - %s", self.jailName, ip, datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S")
)
self.failManager.addFailure(ticket, len(matches) or 1)
# Perform the ban if this attempt is resulted to:
self.performBan(ip)
return 1
## ##
# Ignore own IP/DNS. # Ignore own IP/DNS.

View File

@ -79,12 +79,7 @@ class FilterGamin(FileFilter):
this is a common logic and must be shared/provided by FileFilter this is a common logic and must be shared/provided by FileFilter
""" """
self.getFailures(path) self.getFailures(path)
try: self.performBan()
while True:
ticket = self.failManager.toBan()
self.jail.putFailTicket(ticket)
except FailManagerEmpty:
self.failManager.cleanup(MyTime.time())
self.__modified = False self.__modified = False
## ##

View File

@ -117,12 +117,7 @@ class FilterPoll(FileFilter):
self.ticks += 1 self.ticks += 1
if self.__modified: if self.__modified:
try: self.performBan()
while True:
ticket = self.failManager.toBan()
self.jail.putFailTicket(ticket)
except FailManagerEmpty:
self.failManager.cleanup(MyTime.time())
self.__modified = False self.__modified = False
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
if not self.active: # if not active - error by stop... if not self.active: # if not active - error by stop...

View File

@ -140,12 +140,7 @@ class FilterPyinotify(FileFilter):
""" """
if not self.idle: if not self.idle:
self.getFailures(path) self.getFailures(path)
try: self.performBan()
while True:
ticket = self.failManager.toBan()
self.jail.putFailTicket(ticket)
except FailManagerEmpty:
self.failManager.cleanup(MyTime.time())
self.__modified = False self.__modified = False
def _addPending(self, path, reason, isDir=False): def _addPending(self, path, reason, isDir=False):

View File

@ -300,12 +300,8 @@ class FilterSystemd(JournalFilter): # pragma: systemd no cover
else: else:
break break
if self.__modified: if self.__modified:
try: self.performBan()
while True: self.__modified = 0
ticket = self.failManager.toBan()
self.jail.putFailTicket(ticket)
except FailManagerEmpty:
self.failManager.cleanup(MyTime.time())
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
if not self.active: # if not active - error by stop... if not self.active: # if not active - error by stop...
break break

View File

@ -473,20 +473,24 @@ class Server:
def setBanTime(self, name, value): def setBanTime(self, name, value):
self.__jails[name].actions.setBanTime(value) self.__jails[name].actions.setBanTime(value)
def addAttemptIP(self, name, *args):
return self.__jails[name].filter.addAttempt(*args)
def setBanIP(self, name, value): def setBanIP(self, name, value):
return self.__jails[name].filter.addBannedIP(value) return self.__jails[name].actions.addBannedIP(value)
def setUnbanIP(self, name=None, value=None): def setUnbanIP(self, name=None, value=None, ifexists=True):
if name is not None: if name is not None:
# in all jails: # single jail:
jails = [self.__jails[name]] jails = [self.__jails[name]]
else: else:
# single jail: # in all jails:
jails = self.__jails.values() jails = self.__jails.values()
# unban given or all (if value is None): # unban given or all (if value is None):
cnt = 0 cnt = 0
ifexists |= (name is None)
for jail in jails: for jail in jails:
cnt += jail.actions.removeBannedIP(value, ifexists=(name is None)) cnt += jail.actions.removeBannedIP(value, ifexists=ifexists)
if value and not cnt: if value and not cnt:
logSys.info("%s is not banned", value) logSys.info("%s is not banned", value)
return cnt return cnt

View File

@ -109,10 +109,7 @@ class Transmitter:
# if all ips: # if all ips:
if len(value) == 1 and value[0] == "--all": if len(value) == 1 and value[0] == "--all":
return self.__server.setUnbanIP() return self.__server.setUnbanIP()
cnt = 0 return self.__server.setUnbanIP(None, value)
for value in value:
cnt += self.__server.setUnbanIP(None, value)
return cnt
elif command[0] == "echo": elif command[0] == "echo":
return command[1:] return command[1:]
elif command[0] == "server-status": elif command[0] == "server-status":
@ -189,8 +186,8 @@ class Transmitter:
self.__server.setIgnoreSelf(name, value) self.__server.setIgnoreSelf(name, value)
return self.__server.getIgnoreSelf(name) return self.__server.getIgnoreSelf(name)
elif command[1] == "addignoreip": elif command[1] == "addignoreip":
value = command[2] for value in command[2:]:
self.__server.addIgnoreIP(name, value) self.__server.addIgnoreIP(name, value)
return self.__server.getIgnoreIP(name) return self.__server.getIgnoreIP(name)
elif command[1] == "delignoreip": elif command[1] == "delignoreip":
value = command[2] value = command[2]
@ -285,13 +282,20 @@ class Transmitter:
value = command[2] value = command[2]
self.__server.setBanTime(name, value) self.__server.setBanTime(name, value)
return self.__server.getBanTime(name) return self.__server.getBanTime(name)
elif command[1] == "attempt":
value = command[2:]
return self.__server.addAttemptIP(name, *value)
elif command[1] == "banip": elif command[1] == "banip":
value = command[2] value = command[2:]
return self.__server.setBanIP(name,value) return self.__server.setBanIP(name,value)
elif command[1] == "unbanip": elif command[1] == "unbanip":
value = command[2] ifexists = True
self.__server.setUnbanIP(name, value) if command[2] != "--report-absent":
return value value = command[2:]
else:
ifexists = False
value = command[3:]
return self.__server.setUnbanIP(name, value, ifexists=ifexists)
elif command[1] == "addaction": elif command[1] == "addaction":
args = [command[2]] args = [command[2]]
if len(command) > 3: if len(command) > 3:

View File

@ -78,6 +78,16 @@ class ExecuteActions(LogCaptureTestCase):
self.assertEqual(self.__actions.getBanTime(),127) self.assertEqual(self.__actions.getBanTime(),127)
self.assertRaises(ValueError, self.__actions.removeBannedIP, '127.0.0.1') self.assertRaises(ValueError, self.__actions.removeBannedIP, '127.0.0.1')
def testAddBannedIP(self):
self.assertEqual(self.__actions.addBannedIP('192.0.2.1'), 1)
self.assertLogged('Ban 192.0.2.1')
self.pruneLog()
self.assertEqual(self.__actions.addBannedIP(['192.0.2.1', '192.0.2.2', '192.0.2.3']), 2)
self.assertLogged('192.0.2.1 already banned')
self.assertNotLogged('Ban 192.0.2.1')
self.assertLogged('Ban 192.0.2.2')
self.assertLogged('Ban 192.0.2.3')
def testActionsOutput(self): def testActionsOutput(self):
self.defaultActions() self.defaultActions()
self.__actions.start() self.__actions.start()

View File

@ -1121,7 +1121,7 @@ class Fail2banServerTest(Fail2banClientServerBase):
"--async", "unban", "192.0.2.5", "192.0.2.6") "--async", "unban", "192.0.2.5", "192.0.2.6")
self.assertLogged( self.assertLogged(
"192.0.2.5 is not banned", "192.0.2.5 is not banned",
"[test-jail1] Unban 192.0.2.6", all=True "[test-jail1] Unban 192.0.2.6", all=True, wait=MID_WAITTIME
) )
# reload all (one jail) with unban all: # reload all (one jail) with unban all:
@ -1194,6 +1194,14 @@ class Fail2banServerTest(Fail2banClientServerBase):
"Jail 'test-jail1' stopped", "Jail 'test-jail1' stopped",
"Jail 'test-jail1' started", all=True, wait=MID_WAITTIME) "Jail 'test-jail1' started", all=True, wait=MID_WAITTIME)
# Coverage for pickle of IPAddr (as string):
self.pruneLog("[test-phase end-3]")
self.execCmd(SUCCESS, startparams,
"--async", "set", "test-jail1", "addignoreip", "192.0.2.1/32", "2001:DB8::1/96")
self.execCmd(SUCCESS, startparams,
"--async", "get", "test-jail1", "ignoreip")
self.assertLogged("192.0.2.1/32", "2001:DB8::1/96", all=True)
# test action.d/nginx-block-map.conf -- # test action.d/nginx-block-map.conf --
@unittest.F2B.skip_if_cfg_missing(action="nginx-block-map") @unittest.F2B.skip_if_cfg_missing(action="nginx-block-map")
@with_foreground_server_thread(startextra={ @with_foreground_server_thread(startextra={

View File

@ -394,11 +394,13 @@ class IgnoreIP(LogCaptureTestCase):
self.assertLogged('Ignore 192.168.1.32') self.assertLogged('Ignore 192.168.1.32')
tearDownMyTime() tearDownMyTime()
def testIgnoreAddBannedIP(self): def testAddAttempt(self):
self.filter.addIgnoreIP('192.168.1.0/25') self.filter.setMaxRetry(3)
self.filter.addBannedIP('192.168.1.32') for i in xrange(1, 1+3):
self.assertNotLogged('Ignore 192.168.1.32') self.filter.addAttempt('192.0.2.1')
self.assertLogged('Requested to manually ban an ignored IP 192.168.1.32. User knows best. Proceeding to ban it.') self.assertLogged('Attempt 192.0.2.1', '192.0.2.1:%d' % i, all=True, wait=True)
self.jail.actions._Actions__checkBan()
self.assertLogged('Ban 192.0.2.1', wait=True)
def testIgnoreCommand(self): def testIgnoreCommand(self):
self.filter.ignoreCommand = sys.executable + ' ' + os.path.join(TEST_FILES_DIR, "ignorecommand.py <ip>") self.filter.ignoreCommand = sys.executable + ' ' + os.path.join(TEST_FILES_DIR, "ignorecommand.py <ip>")

View File

@ -330,22 +330,49 @@ class Transmitter(TransmitterBase):
self.server.startJail(self.jailName) # Jail must be started self.server.startJail(self.jailName) # Jail must be started
self.assertEqual( self.assertEqual(
self.transm.proceed(["set", self.jailName, "banip", "127.0.0.1"]), self.transm.proceed(["set", self.jailName, "banip", "192.0.2.1", "192.0.2.1", "192.0.2.2"]),
(0, "127.0.0.1")) (0, 2))
self.assertLogged("Ban 127.0.0.1", wait=True) # Give chance to ban self.assertLogged("Ban 192.0.2.1", "Ban 192.0.2.2", all=True, wait=True) # Give chance to ban
self.assertEqual( self.assertEqual(
self.transm.proceed(["set", self.jailName, "banip", "Badger"]), self.transm.proceed(["set", self.jailName, "banip", "Badger"]),
(0, "Badger")) #NOTE: Is IP address validated? Is DNS Lookup done? (0, 1)) #NOTE: Is IP address validated? Is DNS Lookup done?
self.assertLogged("Ban Badger", wait=True) # Give chance to ban self.assertLogged("Ban Badger", wait=True) # Give chance to ban
# Unban IP # Unban IP (first/last are not banned, so checking unban of both other succeeds):
self.assertEqual( self.assertEqual(
self.transm.proceed( self.transm.proceed(
["set", self.jailName, "unbanip", "127.0.0.1"]), ["set", self.jailName, "unbanip", "192.0.2.255", "192.0.2.1", "192.0.2.2", "192.0.2.254"]),
(0, "127.0.0.1")) (0, 2))
# Unban IP which isn't banned self.assertLogged("Unban 192.0.2.1", "Unban 192.0.2.2", all=True, wait=True)
self.assertLogged("192.0.2.255 is not banned", "192.0.2.254 is not banned", all=True, wait=True)
self.pruneLog()
# Unban IP which isn't banned (error):
self.assertEqual( self.assertEqual(
self.transm.proceed( self.transm.proceed(
["set", self.jailName, "unbanip", "192.168.1.1"])[0],1) ["set", self.jailName, "unbanip", "--report-absent", "192.0.2.255"])[0],1)
# ... (no error, IPs logged only):
self.assertEqual(
self.transm.proceed(
["set", self.jailName, "unbanip", "192.0.2.255", "192.0.2.254"]),(0, 0))
self.assertLogged("192.0.2.255 is not banned", "192.0.2.254 is not banned", all=True, wait=True)
def testJailAttemptIP(self):
self.server.startJail(self.jailName) # Jail must be started
def attempt(ip, matches):
return self.transm.proceed(["set", self.jailName, "attempt", ip] + matches)
self.setGetTest("maxretry", "5", 5, jail=self.jailName)
# produce 2 single attempts per IP:
for i in (1, 2):
for ip in ("192.0.2.1", "192.0.2.2"):
self.assertEqual(attempt(ip, ["test failure %d" % i]), (0, 1))
self.assertLogged("192.0.2.1:2", "192.0.2.2:2", all=True, wait=True)
# this 3 attempts at once should cause a ban:
self.assertEqual(attempt(ip, ["test failure %d" % i for i in (3,4,5)]), (0, 1))
self.assertLogged("192.0.2.2:5", wait=True)
# resulted to ban for "192.0.2.2" but not for "192.0.2.1":
self.assertLogged("Ban 192.0.2.2", wait=True)
self.assertNotLogged("Ban 192.0.2.1")
def testJailMaxRetry(self): def testJailMaxRetry(self):
self.setGetTest("maxretry", "5", 5, jail=self.jailName) self.setGetTest("maxretry", "5", 5, jail=self.jailName)

View File

@ -272,10 +272,13 @@ date/times for <JAIL>
\fBset <JAIL> usedns <VALUE>\fR \fBset <JAIL> usedns <VALUE>\fR
sets the usedns mode for <JAIL> sets the usedns mode for <JAIL>
.TP .TP
\fBset <JAIL> banip <IP>\fR \fBset <JAIL> attempt <IP> [<failure1> ... <failureN>]\fR
manually notify about <IP> failure
.TP
\fBset <JAIL> banip <IP> ... <IP>\fR
manually Ban <IP> for <JAIL> manually Ban <IP> for <JAIL>
.TP .TP
\fBset <JAIL> unbanip <IP>\fR \fBset <JAIL> unbanip [\-\-report\-absent] <IP> ... <IP>\fR
manually Unban <IP> in <JAIL> manually Unban <IP> in <JAIL>
.TP .TP
\fBset <JAIL> maxretry <RETRY>\fR \fBset <JAIL> maxretry <RETRY>\fR