auth_rx_wiki/Working-with-Rulesets.md

23 KiB

The Minetest Authentication Ruleset Schema, or MARS, is a simple rule-based scripting language that allows for realtime, dynamic filtering of login attempts prior to the password verification stage.

An example of a very simple MARS-compliant script is as follows:

# Prevent new players from joining the server

try "Sorry, we are no longer accepting new players!"

fail any
if $is_new eq $true
continue

pass now

Each world can have its own ruleset. An active ruleset file is named "greenlist.mt". It must have read permissions within the corresponding world directory. If you do not wish to filter login attempts, then the single rule "pass now" is sufficient.

The ruleset file will be loaded and cached automatically when the Minetest server is launched. Any changes to the ruleset file will have no effect unless the server is restarted. In the near future, a chat command will be provided to reload the ruleset file on demand without requiring a server restart.

Basic Syntax:

A pass or fail statement defines a new rule and the respective course of action. A matching pass rule, will short circuit the ruleset and accept the request silently whereas a matching fail rule will short circuit the ruleset and reject the request, returning an error. The continue statement causes the conditions of the preceding rule to be evaluated. Therefore, it must appear after a pass or fail statement.

The when and until statements allow for abbreviated rules. They consist of only a single condition followed by either pass or fail as the course of action. Hence, the example above could be rewritten as follows:

try "Sorry, we are no longer accepting new players!"

when $is_new eq $true fail

pass now

The try statement sets an error message to be returned to the client whenever any of the subsequent rules fail. It may only appear outside of a rule and expects a string as its only parameter.

The pass and fail statements must specify a boolean operation for evaluating the subsequent conditions:

  • all - logical 'and', requires all conditions to be true
  • any - logical 'or', requires at least one condition to be true
  • one - logical 'xor', requires exactly one condition to be true
  • now - bypass

Of course, if there are no conditions, then any and one will never match whereas all will always match.

The special boolean operation now immediately short circuits the ruleset. The continue statement may therefore be ommitted. In this way it is customary to end a permissive ruleset with pass now, but a restrictive ruleset with fail now.

This is an example of a ruleset that has no conditions, and rejects all requests.

# Disable access to the server temporarily

try "The server is undergoing maintenance. Please try again later!"
fail now

Blank lines and lines beginning with a '#' character are ignored by the parser so that formatting and commenting is possible anywhere within a ruleset.

Variables and Literals:

Ten datatypes are supported by MARS: string, number, boolean, address, pattern, moment, interval, timespec, datespec, and array. Variables are denoted by a "$" symbol followed by the variable name. The variable name is case sensitive.

$<variable_name>

The following variables are preset by the authentication handler at runtime:

  • $name - username supplied by the client (string)
  • $addr - IPv4 address of the client (string)
  • $privs - list of assigned player privileges (array)
  • $addrs - list of approved client addresses (array)
  • $oldlogin - timestamp of the player's initial login (moment)
  • $newlogin - timestamp of the player's recent login (moment)
  • $is_new - whether an account with the given username exists (boolean)
  • $lifetime - length of time the player has spent in-game (interval)
  • $attempts - number of attempted logins since the account was created (number)
  • $failures - number of failed logins since the account was created (number)
  • $max_users - configured maximum number of connected clients (number)
  • $cur_users - number of clients currently connected to the server (number)
  • $users_list - list of players currently in-game by username (array)
  • $uptime - length of time the server has been online (interval)
  • $clock - timestamp of the system clock (moment)
  • $epoch - timestamp of the UNIX epoch (moment)
  • $owner - username of the server operator (string)

Number literals are represented in the customary integer and floating point notation. String literals are represented as a set of characters between single-quotes or double-quotes. The latter notation allows for inline variable substitution, similar to the interpolation behavior of Perl. Address literals are represented in the standard IPv4 dotted decmal notation. Boolean literals do not exist, but the preset variables $true and $false are provided for boolean comparisons.

Conditions and Comparisons:

A pass or fail rule may contain a sequence of one or more conditions. In order for the rule to match, these conditions are evaluated according to the boolean operation of the rule. Non-matching rules are skipped.

  • if - evaluates the logic of the conditional expression
  • unless - evaluates the inverse logic of the conditional expression

Within a conditional expression, it is possible to compare variables and literals of the same datatype using the following binary comparison operators.

  • is - pattern match (scalar)
  • has - pattern match (array)
  • eq - equal to
  • gt - greater than
  • gte - greater than or equal to
  • lt - less than
  • lte - less than or equal to

The eq operator allows comparisons of nearly any datatype, except for arrays and patterns. The gt, gte, lt, and lte operators only allow comparisons of numbers, intervals, moments, timespecs, and datespecs.

The is and has operators are unique in that they expect a pattern for their right-hand operand (see Patterns below).

There is no coercion of datatypes, however in some cases type conversion can be accomplished implictly through inline variable substitution via string literals (see above). For example, to match a username that is equal to the current number of connected clients, the following condition would suffice.

if $name eq "$cur_users"

This type conversion technique, of course, only works with numbers, strings, and booleans.

Functions:

The syntax of functional expressions is consistent with Javascript, Perl, Python, and various other imperative programming languages:

func(arg1,arg2,...)

An argument can be either a variable, a literal, or a nested function, and it must evaluate to the correct datatype (string, number, etc.) as there is no implicit type conversion. For example, the add() function accepts two numbers, and returns the sum:

pass now
if add(1,1) eq 2
continue

An idiomatic "dataflow" operator is provided for daisy-chaining functions together for sake of readability:

arg1->func1(arg2,...)->func2(arg3,...)

The dataflow operator has left-to-right associativity. So in the expression above, arg1 is evaluated and inserted as the first argument to func1. Then func1 is evaluated and the result is inserted as the first argument to func2.

For example, the following functional expressions are identical, although the first is entirely non-idiomatic:

mul(add(len("TEST"),2),neg(0.5))

"TEST"->len()->add(2)->mul(0.5->neg())

A handful of builtin functions are available for the most common login filtering tasks (for date and time functions, see below):

  • sub(a,b)
    return a - b
  • add(a,b)
    return a + b
  • mul(a,b)
    return a * b
  • div(a,b)
    return a / b
  • neg(a)
    return the negative value of a
  • abs(a)
    return the absolute value of a
  • max(a,b)
    return the maximum value of a or b
  • min(a,b)
    return the minimum value of a or b
  • int(a)
    return the integer part of a
  • uc(a)
    return the uppercase of string a
  • lc(a)
    return the lowercase of string a
  • len(a)
    return the length of string a
  • trim(a,b)
    returns string a shortened by b characters at the end (or beginning, if negative)
  • crop(a,b)
    returns string a shortened to b characters from the beginning (or end, if negative)

For example, the following rule will match for usernames "Admin" and "administrator" and "ADMIN":

fail now
if $name->crop(5) is "admin"
continue

Notice that the is operator permits case-insensitive comparisons, provided both its operands are strings.

Moments and Intervals:

The following specialized data types are available for working with times and dates:

  • Moment - An absolute time value (as in 12:30:00 01-Jan-2018)
  • Interval - A relative time value (as in 15 minutes)
  • Timespec - The time component of a moment (as in 12:30:00)
  • Datespec - The date component of a moment (as in 01-Jan-2018)

Interval literals are represented as a cardinal value with a suffix denoting the time scale

Valid time scales are "y" for year, "w" for week, "d" for day, "h" for hour, "m" for minute, and "s" for second. Intervals are stored internally as seconds, so direct comparisons of literals are always possible, regardless of the time scale:

7d eq 1w
48h eq 2d

Moment literals are represented as an interval offset from the clock (minus prefix) or the epoch (plus prefix)

-<value><scale>

+<value><scale>

In essence, a moment literal is syntactic sugar for the the before() and after() functions, as the following comparisons show:

-10d eq $clock->before(10d)
+64d eq $epoch->after(64d)

The special variables $clock and $epoch always equate to the system clock and the UNIX epoch, the same values can be obtained from the moment literals -0s and +0s:

-0s eq $clock
+0s eq $epoch

A UNIX timestamp can therefore be represented literally, as an offset interval from the epoch in seconds:

+1532278813s

Alternatively, the at() function accepts a full string representation of a date and time in ISO 8601 format for added convenience:

at("2018-01-01T00:00:00Z")

A timespec is represented as a cardinal value in the customary hours, minutes, and seconds notation. The alternate notation, without seconds, is accepted as well. Timespecs are only used for purposes of comparison and do not store an actual timestamp.

<hours>:<minutes>:<seconds>

A datespec is represented as an ordinal value in the day, month, year notation.

<day>-<month>-<year>

The day can be one or two digits, whereas the month must be two digits and the year must be four digits. Datespecs, like their counterparts, are used only for purposes of comparison.

To simplify the conversion between these four datatypes, several helper functions are provided:

  • date(a)
    converts moment a to a datespec
  • time(a)
    converts moment a to a timespec
  • age(a)
    calculates the interval between the system clock and moment a
  • day(a)
    converts moment a to a three-letter string representing the weekday (locale dependent)
  • before(a,b)
    subtracts interval b from moment a
  • after(a,b)
    adds interval b to moment a
  • at(a)
    converts string a in ISO 8601 combined format to a moment (locale dependent)

You can use the date() and time() functions to compare timespecs or datespecs against moments. Say, you want to determine if yesterday was August 1, 2018.

if date(-1d) eq 01-08-2018

The before() and after() functions allow for basic arithmetic of moments and intervals. Maybe you want to determine whether the server was started in the morning.

if time($clock->before($uptime)) lt 12:00

Be advised that the before() and after() functions, with large intervals (like 150 days) could produce results that are either one hour in advance or behind what you might expect, due to crossover from daylight saving to standard time. This is arguably the expected behavior since these functions are performing arithmetic on timestamps, not on a wall clock.

Patterns:

A pattern literal consists of a glob surrounded by forward slashes and a trailing mode indicator.

/<string>/s

/<hours>:<minutes>:<seconds>/t

/<day>-<month>-<year>/d

/<octet1>.<octet2>.<octet3>.<octet4>/a

For string comparisons, the "s" may be omitted, since it is the default parser. For time, date, and address comparisons, the "t", "d", and "a" parsers must be explicitly specified.

The string parser allows for case-sensitive pattern matching of strings, similar to the FCB algorithm employed by MS-DOS. The following wildcards are available:

  • * - match zero or more alphanumeric characters
  • + - match one or more alphanumeric characters
  • ? - match one alphanumeric character
  • # - match one numeric character
  • & - match one alphabetic character
  • , - match one lowercase character
  • ; - match one uppercase character
  • = - match one symbolic character
  • ! - match one alphabetic or numeric character

Note that the alphanumeric set includes A-Z, a-z, 0-9, in addition to the dash and underscore characters. The symbolic set includes only the dash and underscore characters.

The following rule would reject any players having a username beginning with "Guest" or ending with 3-digits:

# Prevent guests and bots from joining the server

fail any
if $name is /Guest*/
if $name is /*###/
continue

Unlike regular expressions, globs are always "anchored". Therefore, to match strings with an arbitrary beginning or end, you must affix wildcards as shown in the example above.

The has comparison operator specifically allows for pattern matching against arrays rather than strings (see Arrays and Data Streams below)

The time, date, and address parsers allow for pattern matching across multiple numeric fields. All three support wildcards as well as range checks:

  • a
    exact match

  • a^b
    match between a and b inclusive

  • a>
    match greater than or equal to a

  • a<
    match less than or equal to a

  • ?
    indefinite match

Let's say that you want to set opening hours of your server between 8:00 and 20:59 daily

until $clock is /8^20:?:?/t fail

Notice that the time() and date() wrapper functions are unnecessary for pattern matching of moments, since the time and date parsers perform these conversions automatically.

Arrays store multiple string values within an ordered set. They can appear as the left-hand operand within conditional expressions, much like scalars. Only string and pattern comparisons are permitted with arrays.

For example, to validate whether a player has the "shout" and "interact" privilege, the following would work:

try "Your privileges are insufficient to join this server."

fail all
unless "shout" in $privs_list
unless "interact" in $privs_list
continue

pass now

When performing comparisons of arrays against strings and patterns, the value of each element is compared in order, returning true as soon as a match is found or false after all elements have been exhausted.

The following three functions are available for working with arrays:

  • split(a,b)
    return an array derived from string a split by string b
  • size(a)
    return the number of elements in array a
  • elem(a,b)
    return element b from array a (or counting from the end, if negative)
  • clip(a,b)
    return the first b elements from array a (or counting from the end, if negative)
  • count(a,b)
    return the number of occurrences of string b in array a

Data streams function like their variable counterparts, except the dataset is stored as a flat-file within the "filters" subdirectory of your world. Such datasets have the advantage that they can be retrieved on-the-fly, without the need to restart the server.

Data streams are denoted by an "@" symbol followed by the file name. The file name is usually case sensitive, depending on the file system, but must end with ".txt".

@<file_name>

With this in mind, a very simple blacklist could be implemented as follows:

--------------------
filters/blacklist.txt
--------------------
administrator
operator
admin
user
server
client
player

--------------------
greenlist.mt
--------------------
try "This account has been restricted."

fail any
if $name in @blacklist.txt
continue

pass now

Thanks to data streams, it is possible to perform huge numbers of repetitive comparisons more efficiently and conveniently than multiple conditions. Consider the ruleset above, only without the the flat-file dataset:

try "This account has been restricted."

fail any
if $name eq "administrator"
if $name eq "operator"
if $name eq "admin"
if $name eq "user"
if $name eq "server"
if $name eq "client"
if $name eq "player"
continue

pass now

The syntax of array literals is similar to that of a function argument list. Elements can be either string literals, string variables, or nested functions that evaluate to a string.

(elem1,elem2,elem3,...)

Array literals can be used anywhere that an array is expected. This makes them a suitable alternative to data streams, particularly when working with a small number of elements.

pass now
if day($clock) in ("Sun","Mon","Tue")
continue

This rule will only match when the current weekday is Sunday, Monday, or Tuesday according to the system clock.

Integrated Debugger:

The MARS integrated debugger attempts to simulate the entire login filtering process within a sandboxed environment. So, you can test your ruleset definitions interactively, without the hassle of launching a new server instance for each and every trial.

By entering the "/fdebug" command into chat (requires the "server privilege"), you will be presented with a debugging console. The workspace consists of the following elements:

  • A. The "Show Client Output" option toggles whether to display the client output panel. The "Show Debug Prompt" option toggles whether to insert debug status prompts into the source code.

  • B. This textarea contains the ruleset definition to be examined. Although Minetest supports editing of text, it is strongly recommended to copy and paste your source code into a full-fledged text editor.

  • C. The client output panel renders error messages as they would appear within the client. The status panel typically indicates whether the ruleset passed or failed, as well as other debugging conditions.

  • D. The "Save" button will export the current ruleset definition, overwriting "greenlist.mt" in your world directory. The "Load" button will import an existing ruleset definition from "greenlist.mt" for debugging.

  • E. The "Process" button will process the ruleset definition according to the selected login filtering criteria: Normal, New Account, or Wrong Password (thereby changing the relevant preset variables).

  • F. The preset variables are listed here with their corresponding values. These values will never change except during the login filtering process, or unless explicitly set in the panel below.

  • G. The name and type of the selected variable is indicated here. The value can be edited in the text field, and set with the "Set" button. The arrow buttons allow for re-ordering any variable within the list.

Some variables, like $clock and $uptime, have an "Auto Update" option to toggle whether the values should be derived from the system state. For a fully sandboxed environment, you can disable this option.

The special $__debug variable gives you direct access to the MARS expression interpreter. You can enter any valid expression, and the resulting value and type will be displayed in the panel above (all variable types, except patterns, are supported). This is particularly helpful for monitoring the values of certain variables. To calculate the size of the $ip_names_list, for example, you would enter

size($ip_names_list)

Whenever a ruleset passes or fails, or if a syntax error is encountered, a debug status prompt will be inserted into the source code below the line in question:

if $name->len() eq "administrator"
# ====== ^ Line 12: Mismatched operands in ruleset^ ======

These breakpoints will be removed automatically when the ruleset definition is saved, so there is no need to edit them out.

Stateful Login Filtering:

The AuthWatchdog class monitors all login activity on the basis of IP address, tracking the time and state of each login request. This makes it very easy to intercept would-be hackers, preventing them from ever reaching the password verification phase.

Six meta-variables are provided by the Watchdog class with state-based information specific to the IP address:

  • $ip_prelogin
    the timestamp of the last attempted login
  • $ip_oldcheck
    the timestamp of the first failed login
  • $ip_newcheck
    the timestamp of the last failed login
  • $ip_failures
    the current number of failed logins
  • $ip_attempts
    the current number of attempted logins
  • $ip_names_list
    list of assumed usernames in chronological order

All meta-variables are persistent until the user successfully logs into the sever, at which point they are reset. If the server is restarted, they will be reset as well (since they are non-essential).

Consider, the frequent scenario of blocking users after multiple failed logins. In a purely Lua-driven approach, this type of mechanism would not only entail hours of coding and testing. But it would become a maintenance (and security) headache. In MARS, however, it's just a few lines of code, virtually no effort to extend or adapt to any number of use-cases:

# block players for 45 seconds after 2 or more failed logins from the same IP address

fail all
if $ip_attempts gt 0
if $ip_failures gte 2
if age($ip_newcheck) lt 45s
continue

Keep in mind, an attempted login is recorded by the AuthWatchdog class directly after the login filtering phase, yet prior to the password verification phase. Hence, if a user at 127.0.0.1 connects to your server for the first time, the $ip_oldcheck, $ip_newcheck, and $ip_prelogin variables will be undefined. Therefore, rules involving the $ip_prelogin variable must first validate $ip_attempts (it will be 0 if this is a newly attempted login). Furthermore, if a login request is rejected by a ruleset, then the password verification phase will aborted. Hence, you must also validate $ip_failures prior to using the $ip_oldcheck and $ip_newcheck variables in your rules.