#!rsc # RouterOS script: global-functions # Copyright (c) 2013-2020 Christian Hesse # Michael Gisbers # # 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 DefaultRouteIsReachable; :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 WaitDefaultRouteReachable; :global WaitForFile; :global WaitTimeSync; # check and download required certificate :set CertificateAvailable do={ :local CommonName [ :tostr $1 ]; :global CertificateDownload; :global LogPrintExit; :global ParseKeyValueStore; :if ([ / system resource get free-hdd-space ] < 8388608 && \ [ / certificate settings get crl-download ] = true && \ [ / certificate settings get crl-store ] = "system") do={ $LogPrintExit warning ("This system has low free flash space but " . \ "is configured to download certificate CRLs to system!") false; } :if ([ / certificate print count-only where common-name=$CommonName ] = 0) do={ $LogPrintExit info ("Certificate with CommonName \"" . $CommonName . "\" not available.") false; :if ([ $CertificateDownload $CommonName ] = false) do={ :return false; } } :local CertVal; :local Issuer $CommonName; :do { :if ([ / certificate print count-only where common-name=$Issuer ] = 0) do={ $LogPrintExit info ("Certificate chain for \"" . $CommonName . \ "\" is incomplete, missing \"" . $Issuer . "\".") false; :if ([ $CertificateDownload $CommonName ] = false) do={ :return false; } } :set CertVal [ / certificate get [ find where common-name=$Issuer ] ]; :set Issuer ([ $ParseKeyValueStore ($CertVal->"issuer") ]->"CN"); } while=($Issuer != $CertVal->"common-name"); :return true; } # download and import certificate :set CertificateDownload do={ :local CommonName [ :tostr $1 ]; :global ScriptUpdatesBaseUrl; :global ScriptUpdatesUrlSuffix; :global CertificateNameByCN; :global LogPrintExit; :global UrlEncode; :global WaitForFile; $LogPrintExit info ("Downloading and importing certificate with " . \ "CommonName \"" . $CommonName . "\".") false; :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={ $LogPrintExit warning ("Failed imprting certificate!") false; :return false; } :return true; } # 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; } # default route is reachable :set DefaultRouteIsReachable do={ :if ([ / ip route print count-only where dst-address=0.0.0.0/0 !unreachable active !routing-mark ] > 0) do={ :return true; } :return false; } # 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 LogPrintExit; :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) ]; :if ([ / file print count-only where name=$PkgDest type="package" ] > 0) do={ $LogPrintExit info ("Package file alreasy exists.") false; :return true; } :if ([ $CertificateAvailable "Let's Encrypt Authority X3" ] = false) do={ $LogPrintExit error ("Downloading required certificate failed.") true; } :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; :global LogPrintExit; :do { :if ([ $CertificateAvailable "Let's Encrypt Authority X3" ] = false) do={ $LogPrintExit warning ("Downloading required certificate failed.") true; } :local 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; :global LogPrintExit; :if ([ / tool netwatch print count-only where comment=$MailServer ] = 0) do={ $LogPrintExit warning ("Adding netwatch entry for mail server.") false; :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 ]; :global LogPrintExit; :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={ $LogPrintExit debug ("Script " . $Script . " started from terminal.") false; :return true; } } $LogPrintExit debug ("Script " . $Script . " NOT started from terminal.") false; :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" ] 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 info ($NotificationMessage) false; $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 ]); $LogPrintExit info ("Change: " . $GlobalConfigChanges->[ :tostr $I ]) false; } :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; :global LogPrintExit; :if ([ :len $EmailGeneralTo ] = 0) do={ :return false; } :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={ $LogPrintExit warning ("Failed sending notification mail!") false; } } # 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 CertificateAvailable; :global LogPrintExit; :global UrlEncode; :local ChatId $TelegramChatId; :if ([ :len $TelegramChatIdOverride ] > 0) do={ :set ChatId $TelegramChatIdOverride; } :if ([ :len $TelegramTokenId ] = 0 || [ :len $ChatId ] = 0) do={ :return false; } :do { :if ([ $CertificateAvailable "Go Daddy Secure Certificate Authority - G2" ] = false) do={ $LogPrintExit warning ("Downloading required certificate failed.") true; } / 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={ $LogPrintExit warning ("Failed sending telegram notification!") false; } } # check if system time is sync :set TimeIsSync do={ :if ([ / system ntp client get enabled ] = true) do={ :do { :if ([ / system ntp client get status ] = "synchronized") do={ :return true; } } on-error={ :if ([ :typeof [ / system ntp client get last-adjustment ] ] = "time") 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 default route to be reachable :set WaitDefaultRouteReachable do={ :global DefaultRouteIsReachable; :while ([ $DefaultRouteIsReachable ] = false) do={ :delay 1s; } } # 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;