aboutsummaryrefslogtreecommitdiffstats
#!rsc
# RouterOS script: global-functions
# Copyright (c) 2013-2020 Christian Hesse <mail@eworm.de>
#                         Michael Gisbers <michael@gisbers.de>
#
# global functions
# https://git.eworm.de/cgit/routeros-scripts/about/

# expected configuration version
:global ExpectedConfigVersion 16;

# global variables not to be changed by user
:global GlobalFunctionsReady false;
:global Identity [ / system identity get name ];
:global SentConfigChangesNotification "-";
:global SentLteFirmwareUpgradeNotification "-";
:global SentRouterosUpdateNotification "-";

# global functions
:global CertificateAvailable;
:global CertificateDownload;
:global CertificateNameByCN;
:global CharacterReplace;
:global CleanFilePath;
:global DeviceInfo;
:global DownloadPackage;
:global GetMacVendor;
:global GetRandom;
:global LogPrintExit;
:global MailServerIsUp;
:global MkDir;
:global ParseKeyValueStore;
:global RandomDelay;
:global ScriptFromTerminal;
:global ScriptInstallUpdate;
:global ScriptLock;
:global SendEMail;
:global SendNotification;
:global SendTelegram;
:global TimeIsSync;
:global UrlEncode;
:global WaitForFile;
:global WaitTimeSync;

# check and download required certificate
:set CertificateAvailable do={
  :local CommonName [ :tostr $1 ];

  :global CertificateDownload;
  :global ParseKeyValueStore;

  :if ([ / system resource get free-hdd-space ] < 8388608 && \
       [ / certificate settings get crl-download ] = true && \
       [ / certificate settings get crl-store ] = "system") do={
    :log warning "This system has low free flash space but is configured to download certificate CRLs to system!";
  }

  :if ([ / certificate print count-only where common-name=$CommonName ] = 0) do={
    :log info ("Certificate with CommonName \"" . $CommonName . "\" not available.");
    $CertificateDownload $CommonName;
  }

  :local CertVal;
  :local Issuer $CommonName;
  :do {
    :if ([ / certificate print count-only where common-name=$Issuer ] = 0) do={
      :log info ("Certificate chain for \"" . $CommonName . "\" is incomplete, missing \"" . $Issuer . "\".");
      $CertificateDownload $CommonName;
    }
    :set CertVal [ / certificate get [ find where common-name=$Issuer ] ];
    :set Issuer ([ $ParseKeyValueStore ($CertVal->"issuer") ]->"CN");
  } while=($Issuer != $CertVal->"common-name");
}

# download and import certificate
:set CertificateDownload do={
  :local CommonName [ :tostr $1 ];

  :global ScriptUpdatesBaseUrl;
  :global ScriptUpdatesUrlSuffix;

  :global CertificateNameByCN;
  :global UrlEncode;
  :global WaitForFile;

  :log info ("Downloading and importing certificate with " . \
      "CommonName \"" . $CommonName . "\".");
  :do {
    :local LocalFileName ($CommonName . ".pem");
    :local UrlFileName ([ $UrlEncode $CommonName ] . ".pem");
    / tool fetch check-certificate=yes-without-crl \
      ($ScriptUpdatesBaseUrl . "certs/" . \
      $UrlFileName . $ScriptUpdatesUrlSuffix) \
      dst-path=$LocalFileName;
    $WaitForFile $LocalFileName;
    / certificate import file-name=$LocalFileName passphrase="";
    / file remove $LocalFileName;

    :foreach Cert in=[ / certificate find where name~("^" . $LocalFileName . "_[0-9]+\$") ] do={
      $CertificateNameByCN [ / certificate get $Cert common-name ];
    }
  } on-error={
    :log warning "Failed imprting certificate!";
  }
}

# name a certificate by its common-name
:set CertificateNameByCN do={
  :local CommonName [ :tostr $1 ];

  :global CharacterReplace;

  :local Cert [ / certificate find where common-name=$CommonName ];
  / certificate set $Cert name=[ $CharacterReplace [ $CharacterReplace $CommonName " " "-" ] "---" "-" ];
}

# character replace
:set CharacterReplace do={
  :local String [ :tostr $1 ];
  :local ReplaceFrom [ :tostr $2 ];
  :local ReplaceWith [ :tostr $3 ];
  :local Return "";

  :if ($ReplaceFrom = "") do={
    :return $String;
  }

  :while ([ :typeof [ :find $String $ReplaceFrom ] ] != "nil") do={
    :local Pos [ :find $String $ReplaceFrom ];
    :set Return ($Return . [ :pick $String 0 $Pos ] . $ReplaceWith);
    :set String [ :pick $String ($Pos + [ :len $ReplaceFrom ]) [ :len $String ] ];
  }

  :return ($Return . $String);
}

# clean file path
:set CleanFilePath do={
  :local Path [ :tostr $1 ];

  :global CharacterReplace;

  :while ($Path ~ "//") do={
    :set $Path [ $CharacterReplace $Path "//" "/" ];
  }
  :if ([ :pick $Path 0 ] = "/") do={
    :set Path [ :pick $Path 1 [ :len $Path ] ];
  }
  :if ([ :pick $Path ([ :len $Path ] - 1) ] = "/") do={
    :set Path [ :pick $Path 0 ([ :len $Path ] - 1) ];
  }

  :return $Path;
}

# get readable device info
:set DeviceInfo do={
  :global ExpectedConfigVersion;
  :global GlobalConfigVersion;
  :global Identity;

  :local Resource [ / system resource get ];
  :local RouterBoard [ / system routerboard get ];
  :local Update [ / system package update get ];

  :local Info ( \
    "Hostname:       " . $Identity . "\n" . \
    "Board name:     " . $Resource->"board-name" . "\n" . \
    "Architecture:   " . $Resource->"architecture-name");
  :if ($RouterBoard->"routerboard" = true) do={
    :local Revision "";
    :if ([ :len ($RouterBoard->"revision") ] > 0) do={
      :set Revision (" " . $RouterBoard->"revision");
    }
    :set Info ($Info . "\n" . \
      "Model:          " . $RouterBoard->"model" . $Revision . "\n" . \
      "Serial number:  " . $RouterBoard->"serial-number");
  }
  :set Info ($Info . "\n" . \
    "RouterOS:\n" . \
    "    Channel:    " . $Update->"channel" . "\n" . \
    "    Installed:  " . $Update->"installed-version");
  :if ([ :typeof ($Update->"latest-version") ] != "nothing" && \
       $Update->"installed-version" != $Update->"latest-version") do={
    :set Info ($Info . "\n" . \
      "    Available:  " . $Update->"latest-version");
  }
  :set Info ($Info . "\n" . \
      "RouterOS-Scripts Configuration Version:\n" . \
      "    Current:    " . $GlobalConfigVersion);
  :if ($GlobalConfigVersion != $ExpectedConfigVersion) do={
    :set Info ($Info . "\n" . \
      "    Expected:   " . $ExpectedConfigVersion);
  }

  :return $Info;
}

# download package from upgrade server
:set DownloadPackage do={
  :local PkgName [ :tostr $1 ];
  :local PkgVer  [ :tostr $2 ];
  :local PkgArch [ :tostr $3 ];
  :local PkgDir  [ :tostr $4 ];

  :global CertificateAvailable;
  :global CleanFilePath;
  :global WaitForFile;

  :if ([ :len $PkgName ] = 0) do={ return false; }
  :if ([ :len $PkgVer  ] = 0) do={ :set PkgVer  [ / system package update get installed-version ]; }
  :if ([ :len $PkgArch ] = 0) do={ :set PkgArch [ / system resource get architecture-name ]; }

  :local PkgFile ($PkgName . "-" . $PkgVer . "-" . $PkgArch . ".npk");
  :if ($PkgArch = "x86_64") do={
    :set PkgFile ($PkgName . "-" . $PkgVer . ".npk");
  }
  :local PkgDest [ $CleanFilePath ($PkgDir . "/" . $PkgFile) ];

  $CertificateAvailable "Let's Encrypt Authority X3";

  :local Retry 3;
  :while ($Retry > 0) do={
    :do {
      / tool fetch check-certificate=yes-without-crl \
        ("https://upgrade.mikrotik.com/routeros/" . $PkgVer . "/" . $PkgFile) \
        dst-path=$PkgDest;
      $WaitForFile $PkgDest;

      :if ([ / file get [ find where name=$PkgDest ] type ] = "package") do={
        :return true;
      }
    } on-error={
      # catch error and fall through
    }

    / file remove [ find where name=$PkgDest ];
    :set Retry ($Retry - 1);
  }

  :return false;
}

# get MAC vendor
:set GetMacVendor do={
  :local Mac [ :tostr $1 ];

  :global CertificateAvailable;

  :do {
    :local Vendor;
    $CertificateAvailable "Let's Encrypt Authority X3";
    :set Vendor ([ / tool fetch check-certificate=yes-without-crl \
        ("https://api.macvendors.com/" . [ :pick $Mac 0 8 ]) output=user as-value ]->"data");
    :return $Vendor;
  } on-error={
    :return "unknown vendor";
  }
}

# generate random number
# Warning: This is a *very* weak algorithm and in *no way*
# useful for cryptography or similar!
:set GetRandom do={
  :local Max ([ :tonum $1 ] + 1);
  :local Sum 0;

  :foreach Interface in=[ /interface find ] do={
    :set Sum ($Sum + [ /interface get $Interface tx-byte ]);
  }
  :return ($Sum % $Max);
}

# log and print with same text, optionally exit
:set LogPrintExit do={
  :local Severity [ :tostr $1 ];
  :local Message  [ :tostr $2 ];
  :local Exit     [ :tostr $3 ];

  :global PrintDebug;

  :if ($Severity ~ "^(debug|error|info)\$") do={
    :if ($Severity = "debug") do={ :log debug $Message; }
    :if ($Severity = "error") do={ :log error $Message; }
    :if ($Severity = "info" ) do={ :log info  $Message; }
  } else={
    :log warning $Message;
  }

  :if ($Severity != "debug" || $PrintDebug = true) do={
    :if ($Exit = "true") do={
      :error ($Severity . ": " . $Message);
    } else={
      :put ($Severity . ": " . $Message);
    }
  }
}

# check if mail server is up
:set MailServerIsUp do={
  :local MailServer [ / tool e-mail get address ];
  :local MailHost $MailServer;

  :if ([ / tool netwatch print count-only where comment=$MailServer ] = 0) do={
    :log warning ("Adding netwatch entry for mail server.");
    :local MailHost $MailServer;
    :if ([ :typeof [ :toip $MailHost ] ] != "ip" ) do={
      :set MailHost [ :resolve $MailServer ];
    }
    / tool netwatch add comment=$MailServer host=[ :resolve $MailServer ];
  }

  :if ([ / tool netwatch get [ find where comment=$MailServer ] status ] = "up") do={
    :return true;
  }

  :return false;
}

# create directory
:set MkDir do={
  :local Dir [ :tostr $1 ];

  :global WaitForFile;

  :if ([ / file print count-only where name=$Dir type="directory" ] = 0) do={
    :local WwwVal [ / ip service get www ];
    / ip service set www address=127.0.0.1/32 disabled=no port=80;
    / tool fetch http://127.0.0.1/ dst-path=($Dir . "/tmp");
    $WaitForFile ($Dir . "/tmp");
    / file remove ($Dir . "/tmp");
    / ip service set www address=($WwwVal->"address") \
        disabled=($WwwVal->"disabled") port=($WwwVal->"port");
  }
}

# parse key value store
:set ParseKeyValueStore do={
  :global CharacterReplace;

  :local Source $1;
  :if ([ :typeof $Source ] != "array") do={
    :set Source [ :tostr $1 ];
  }
  :local Result [ :toarray "" ];
  :foreach KeyValue in=[ :toarray $Source ] do={
    :set KeyValue [ :toarray [ $CharacterReplace $KeyValue "=" "," ] ];
    :set ($Result->($KeyValue->0)) ($KeyValue->1);
  }
  :return $Result;
}

# delay a random amount of seconds
:set RandomDelay do={
  :global GetRandom;

  :delay ([ $GetRandom $1 ] . "s");
}

# check if script is run from terminal
:set ScriptFromTerminal do={
  :local Script [ :tostr $1 ];

  :foreach Job in=[ / system script job find where script=$Script ] do={
    :set Job [ / system script job get $Job ];
    :while ([ :typeof ($Job->"parent") ] = "id") do={
      :set Job [ / system script job get [ find where .id=($Job->"parent") ] ];
    }
    :if (($Job->"type") = "login") do={
      :log debug ("Script " . $Script . " started from terminal.");
      :return true;
    }
  }

  :return false;
}

# install new scripts, update existing scripts
:set ScriptInstallUpdate do={
  :local Scripts [ :toarray $1 ];

  :global ExpectedConfigVersion;
  :global GlobalConfigVersion;
  :global Identity;
  :global IDonate;
  :global ScriptUpdatesBaseUrl;
  :global ScriptUpdatesFetch;
  :global ScriptUpdatesIgnore;
  :global ScriptUpdatesUrlSuffix;
  :global SentConfigChangesNotification;

  :global LogPrintExit;
  :global SendNotification;

  :foreach Script in=$Scripts do={
    :if ([ / system script print count-only where name=$Script ] = 0) do={
      $LogPrintExit info ("Adding new script: " . $Script) false;
      / system script add name=$Script source="#!rsc";
    }
  }

  :foreach Script in=[ / system script find where source~"^#!rsc" or source="" ] do={
    :local Ignore 0;
    :local ScriptVal [ / system script get $Script ];
    :local ScriptFile [ / file find where name=("script-updates/" . $ScriptVal->"name") ];
    :local SourceNew;
    :if ([ :len $ScriptFile ] > 0) do={
      :set SourceNew [ / file get $ScriptFile content ];
      / file remove $ScriptFile;
    }

    :foreach Scheduler in=[ / system scheduler find where on-event~("\\b" . $ScriptVal->"name" . "\\b") ] do={
      :local SchedulerVal [ / system scheduler get $Scheduler ];
      :if ($ScriptVal->"policy" != $SchedulerVal->"policy") do={
        $LogPrintExit warning ("Policies differ for script " . $ScriptVal->"name" . \
          " and its scheduler " . $SchedulerVal->"name" . "!") false;
      }
      :if ($SchedulerVal->"name" != "global-scripts" && \
           $SchedulerVal->"start-time" = "startup" && \
           $SchedulerVal->"interval" = 0s && \
           !(($SchedulerVal->"on-event") ~ "\\brun global-wait\\b")) do={
        $LogPrintExit warning ("Scheduler " . $SchedulerVal->"name" . " starts on startup, " . \
          "without waiting for global-functions. Run 'global-wait' to avoid race conditions!") false;
      }
    }

    :if ([ :len $SourceNew ] = 0 && $ScriptUpdatesFetch = true) do={
      :foreach IgnoreLoop in=$ScriptUpdatesIgnore do={
        :if ($IgnoreLoop = $ScriptVal->"name") do={ :set Ignore 1; }
      }

      :if ($Ignore = 0) do={
        $LogPrintExit debug ("Fetching script from url: " . $ScriptVal->"name") false;
        :do {
          :local Result [ / tool fetch check-certificate=yes-without-crl \
              ($ScriptUpdatesBaseUrl . $ScriptVal->"name" . $ScriptUpdatesUrlSuffix) \
              output=user as-value ];
          :if ($Result->"status" = "finished") do={
            :set SourceNew ($Result->"data");
          }
        } on-error={
          $LogPrintExit warning ("Failed fetching " . $ScriptVal->"name") false;
        }
      }
    }

    :if ([ :len $SourceNew ] > 0) do={
      :if ([ :pick $SourceNew 0 5 ] = "#!rsc") do={
        :if ($SourceNew != $ScriptVal->"source") do={
          :local DontRequirePermissions \
              ($SourceNew~"\n# requires: dont-require-permissions=yes\n");
          $LogPrintExit info ("Updating script: " . $ScriptVal->"name") false;
          / system script set owner=($ScriptVal->"name") source=$SourceNew \
              dont-require-permissions=$DontRequirePermissions $Script;
          :if ($ScriptVal->"name" = "global-config" && \
               [ / system script print count-only where name="global-config-overlay" ] > 0) do={
            / system script { run global-config; run global-config-overlay; }
          }
          :if ($ScriptVal->"name" = "global-functions") do={
            / system script run global-functions;
          }
        } else={
          $LogPrintExit debug ("Script " .  $ScriptVal->"name" . " did not change.") false;
        }
      } else={
        $LogPrintExit warning ("Looks like new script " . $ScriptVal->"name" . " is not valid. Ignoring!") false;
      }
    } else={
      $LogPrintExit debug ("No update for script " . $ScriptVal->"name" . ".") false;
    }
  }

  :if ($SentConfigChangesNotification!=$ExpectedConfigVersion && \
       $GlobalConfigVersion < $ExpectedConfigVersion) do={
    :global GlobalConfigChanges;
    :local ChangeLogCode;
    :local ConfigScript "global-config";
    :if ([ /system script print count-only where name="global-config-overlay" ] > 0) do={
      :set ConfigScript "global-config-overlay";
    }
    :local NotificationMessage ("Current configuration on " . $Identity . \
        " is out of date. Please update " . $ConfigScript . ", then increase " . \
        "\$GlobalConfigVersion (currently " . $GlobalConfigVersion . \
        ") to " . $ExpectedConfigVersion . " and re-run " . $ConfigScript . ".");

    $LogPrintExit debug ("Fetching changelog.") false;
    :do {
      :local Result [ / tool fetch check-certificate=yes-without-crl \
          ($ScriptUpdatesBaseUrl . "global-config.changes" . $ScriptUpdatesUrlSuffix) \
          output=user as-value ];
      :if ($Result->"status" = "finished") do={
        :set ChangeLogCode ($Result->"data");
      }
      :set NotificationMessage ($NotificationMessage . "\n\nChanges:");
      [ :parse $ChangeLogCode ];
      :for I from=($GlobalConfigVersion + 1) to=$ExpectedConfigVersion do={
        :set NotificationMessage ($NotificationMessage . \
            "\n * " . $GlobalConfigChanges->[ :tostr $I ]);
      }
      :set GlobalConfigChanges;
    } on-error={
      $LogPrintExit warning ("Failed fetching changes!") false;
      :set NotificationMessage ($NotificationMessage . \
          "\n\nChanges are not available.");
    }

    :if ($IDonate != true) do={
      :set NotificationMessage ($NotificationMessage . \
        "\n\n==== donation hint ====\n" . \
        "This project is developed in private spare time and usage is " . \
        "free of charge for you. If you like the scripts and think this is " . \
        "of value for you or your business please consider a donation:\n" . \
        "https://git.eworm.de/cgit/routeros-scripts/about/#donate");
    }

    $SendNotification "Configuration warning!" $NotificationMessage;
    :set SentConfigChangesNotification $ExpectedConfigVersion;
  }
}

# lock script against multiple invocation
:set ScriptLock do={
  :global LogPrintExit;

  :local Script [ :tostr $1 ];

  :if ([ / system script job print count-only where script=$Script ] > 1) do={
    $LogPrintExit info ("Script " . $Script . " started more than once... Aborting.") true;
  }
}

# send notification via e-mail
:set SendEMail do={
  :local Subject [ :tostr $1 ];
  :local Message [ :tostr $2 ];
  :local Attach  [ :tostr $3 ];

  :global Identity;
  :global EmailGeneralTo;
  :global EmailGeneralCc;

  :if ([ :len $EmailGeneralTo ] = 0) do={
    :return;
  }

  :do {
    :local Signature [ / system note get note ];
    :if ([ :len $Signature ] > 0) do={
      :set Signature ("\n-- \n" . $Signature);
    }
    / tool e-mail send to=$EmailGeneralTo cc=$EmailGeneralCc \
      subject=("[" . $Identity . "] " . $Subject) \
      body=($Message . $Signature) file=$Attach;
  } on-error={
    :log warning "Failed sending notification mail!";
  }
}

# send notification via e-mail and telegram
# Note that attachment is ignored for telegram, silent is ignored for e-mail!
:set SendNotification do={
  :local Subject [ :tostr $1 ];
  :local Message [ :tostr $2 ];
  :local Attach  [ :tostr $3 ];
  :local Silent  [ :tostr $4 ];

  :global SendEMail;
  :global SendTelegram;

  $SendEMail $Subject $Message $Attach;
  $SendTelegram $Subject $Message $Silent;
}

# send notification via telegram
:set SendTelegram do={
  :local Subject [ :tostr $1 ];
  :local Message [ :tostr $2 ];
  :local Silent  [ :tostr $3 ];

  :global Identity;
  :global TelegramTokenId;
  :global TelegramChatId;
  :global TelegramChatIdOverride;

  :global UrlEncode;
  :global CertificateAvailable;

  :local ChatId $TelegramChatId;
  :if ([ :len $TelegramChatIdOverride ] > 0) do={
    :set ChatId $TelegramChatIdOverride;
  }

  :if ([ :len $TelegramTokenId ] = 0 || [ :len $ChatId ] = 0) do={
    :return;
  }

  $CertificateAvailable "Go Daddy Secure Certificate Authority - G2";
  :do {
    / tool fetch check-certificate=yes-without-crl output=none http-method=post \
      ("https://api.telegram.org/bot" . $TelegramTokenId . "/sendMessage") \
      http-data=("chat_id=" . $ChatId . "&disable_notification=" . $Silent . \
      "&text=" . [ $UrlEncode ("[" . $Identity . "] " . $Subject . "\n\n" . $Message) ]);
  } on-error={
    :log warning "Failed sending telegram notification!";
  }
}

# check if system time is sync
:set TimeIsSync do={
  :if ([ / system ntp client get enabled ] = true && \
       [ / system ntp client get status ] = "synchronized") do={
    :return true;
  }

  :if ([ / ip cloud get update-time ] = true && \
       [ :typeof [ / ip cloud get public-address ] ] = "ip") do={
    :return true;
  }

  :return false;
}

# url encoding
:set UrlEncode do={
  :local Input [ :tostr $1 ];
  :local Return "";

  :if ([ :len $Input ] > 0) do={
    :local Chars " !\"#\$%&'()*+,:;<=>\?@[\\]^`{|}~";
    :local Subs { "%20"; "%21"; "%22"; "%23"; "%24"; "%25"; "%26"; "%27"; "%28"; "%29";
                  "%2A"; "%2B"; "%2C"; "%3A"; "%3B"; "%3C"; "%3D"; "%3E"; "%3F"; "%40";
                  "%5B"; "%5C"; "%5D"; "%5E"; "%60"; "%7B"; "%7C"; "%7D"; "%7E" };

    :for I from=0 to=([ :len $Input ] - 1) do={
      :local Char [ :pick $Input $I ];
      :local Replace [ :find $Chars $Char ];

      :if ([ :len $Replace ] > 0) do={
        :set Char ($Subs->$Replace);
      }
      :set Return ($Return . $Char);
    }
  }

  :return $Return;
}

# wait for file to be available
:set WaitForFile do={
  :global CleanFilePath;

  :local FileName [ $CleanFilePath [ :tostr $1 ] ];
  :local I 0;

  :while ([ file print count-only where name=$FileName ] = 0) do={
    :if ($I > 20) do={
      :return false;
    }
    :delay 100ms;
    :set I ($I + 1);
  }
  :return true;
}

# wait for time to become synced
:set WaitTimeSync do={
  :global TimeIsSync;

  :while ([ $TimeIsSync ] = false) do={
    :if ([ / system script print count-only where name="rotate-ntp" ] > 0 && \
         [ :tostr [ / system resource get uptime ] ] ~ "00\$") do={
      / system script run rotate-ntp;
    }
    :delay 1s;
  }
}

# signal we are ready
:set GlobalFunctionsReady true;