#!rsc
# RouterOS script: global-functions
# Copyright (c) 2013-2020 Christian Hesse <mail@eworm.de>
#                         Michael Gisbers <michael@gisbers.de>
#
# global functions

# expected configuration version
:global ExpectedConfigVersion 13;

# global variables not to be changed by user
: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 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;
}

# 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 ];

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

# 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={
    :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 UrlEncode;
  :global CertificateAvailable;

  :if ([ :len $TelegramTokenId ] > 0 && [ :len $TelegramChatId ] > 0) do={
    $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=" . $TelegramChatId . "&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;
  }
}