add RouterOS auto-update script
Scheduled script that recreates the container only when the published :stable image digest actually changed — no wasteful re-pulls. Compares the registry manifest digest (anonymous Gitea token + :deserialize for the token JSON) against a stored digest; recreates + records on change. Verified end-to-end on RouterOS 7.21.2: - token URL omits &service= (& is RouterOS's AND operator and breaks url=) - header digest parsed case-insensitively from the flat http-headers string - container identified by name; mounts via mountlists (list=, not name=) - stop/start waits retry the operation (remove/start) rather than polling a status string, which never matched and forced full timeouts - no /container get ... status (status is a flag, not a gettable property) - installed as a named /system/script (NOT /import, which only executes once)
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
# =============================================================================
|
||||
# mikrotik-tailscale: automatic container update check
|
||||
# =============================================================================
|
||||
# Checks the Gitea registry for a new :stable image and, only if the published
|
||||
# image actually changed, recreates the container. Designed for RouterOS 7.x
|
||||
# (tested target: 7.21.2, arm64). Requires RouterOS >= 7.13 for the :deserialize
|
||||
# command used to parse the registry token JSON.
|
||||
#
|
||||
# HOW IT DECIDES "something changed":
|
||||
# It fetches the manifest digest of the :stable tag from the registry and
|
||||
# compares it to the digest stored from the last successful deploy. The
|
||||
# :stable tag only moves on a MEANINGFUL release (Tailscale bump -> mt.1, or a
|
||||
# manual mt.N); dependency-only rebuilds never republish, so the digest is a
|
||||
# reliable "should I update" signal. No update -> no pull -> no flash wear.
|
||||
#
|
||||
# AUTH:
|
||||
# The Gitea package is public, but the Docker v2 API still needs a bearer
|
||||
# token. Gitea issues an anonymous token from /v2/token for public repos, so
|
||||
# no credentials are stored here.
|
||||
#
|
||||
# INSTALL (one-time):
|
||||
# 1. Edit the CONFIG section below to match your deployment.
|
||||
# 2. Upload this file to the router, then create a NAMED SCRIPT from it:
|
||||
# /system/script/add name=update-tailscale \
|
||||
# source=[/file/get update-tailscale.rsc contents]
|
||||
# NOTE: do NOT use "/import file=update-tailscale.rsc" — :import merely
|
||||
# *executes* the file's commands once (running an update immediately); it
|
||||
# does NOT create a reusable /system/script object. The scheduler below
|
||||
# runs the script by name, so it must exist as a named script.
|
||||
# (If you later edit the file, re-run the add with the ; replace it via
|
||||
# /system/script/set, or remove+add.)
|
||||
# 3. Schedule it: see the /system/scheduler command at the bottom of this file.
|
||||
#
|
||||
# The script is idempotent and safe to run on a schedule.
|
||||
# =============================================================================
|
||||
|
||||
:local scriptName "update-tailscale"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# CONFIG -- edit these to match your setup
|
||||
# ----------------------------------------------------------------------------
|
||||
# Registry / image
|
||||
:local regHost "gitea.lumpiasty.xyz"
|
||||
:local repo "lumpiasty/mikrotik-tailscale"
|
||||
:local tag "stable"
|
||||
# Full image reference RouterOS uses to pull (must include the tag).
|
||||
:local imageRef "gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale:stable"
|
||||
|
||||
# Where the last-deployed digest is remembered between runs.
|
||||
:local stateFile "tailscale-image.digest"
|
||||
|
||||
# --- /container add parameters (must match your working deployment) ---------
|
||||
# These are reused verbatim when recreating the container. They MUST match the
|
||||
# values used in the deployment guide (docs/USAGE.md) so the new container is
|
||||
# identical to the one being replaced.
|
||||
:local cName "tailscale"
|
||||
:local cRootDir "tailscale/root"
|
||||
:local cMountList "tailscale_state"
|
||||
:local cInterface "veth-tailscale"
|
||||
:local cLogging yes
|
||||
:local cStartOnBoot yes
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
:log info "$scriptName: checking for image updates"
|
||||
|
||||
# --- 0. Don't run concurrently -----------------------------------------------
|
||||
# A slow pull/extract could overlap the next scheduled run; bail if another
|
||||
# instance of this script is already running.
|
||||
:if ([/system/script/job/print count-only as-value where script=[:jobname]] > 1) do={
|
||||
:log warning "$scriptName: another instance is already running; exiting"
|
||||
:error "already running"
|
||||
}
|
||||
|
||||
# --- 1. Get an (anonymous) registry bearer token ----------------------------
|
||||
# The response body is JSON ({"token":"..."}); parse it with :deserialize
|
||||
# (RouterOS >= 7.13) instead of fragile string slicing.
|
||||
#
|
||||
# NOTE: the URL has NO "&service=..." parameter on purpose. In RouterOS "&" is
|
||||
# the logical-AND operator and breaks the url= argument ("Please provide IP
|
||||
# address or host"), even inside a quoted string. Gitea issues a usable token
|
||||
# from just ?scope=..., so the service= param is omitted to avoid the "&".
|
||||
:local tokenUrl "https://$regHost/v2/token?scope=repository:$repo:pull"
|
||||
:local token ""
|
||||
:onerror e in={
|
||||
:local tr [/tool fetch url=$tokenUrl as-value output=user]
|
||||
:if (($tr->"status") = "finished") do={
|
||||
:local obj [:deserialize from=json value=($tr->"data")]
|
||||
:set token ($obj->"token")
|
||||
}
|
||||
} do={
|
||||
:log error "$scriptName: token fetch failed: $e"
|
||||
:error "token fetch failed"
|
||||
}
|
||||
:if ([:typeof $token] != "str" || [:len $token] = 0) do={
|
||||
:log error "$scriptName: could not parse registry token"
|
||||
:error "no token"
|
||||
}
|
||||
|
||||
# --- 2. Fetch the :stable manifest and read its digest -----------------------
|
||||
# We request the OCI index media type and read the Docker-Content-Digest
|
||||
# response header, which is the canonical manifest-list digest.
|
||||
:local manUrl "https://$regHost/v2/$repo/manifests/$tag"
|
||||
:local hdrs "Authorization:Bearer $token,Accept:application/vnd.oci.image.index.v1+json"
|
||||
:local newDigest ""
|
||||
:onerror e in={
|
||||
:local mr [/tool fetch url=$manUrl http-header-field=$hdrs as-value output=user-with-headers]
|
||||
:if (($mr->"status") = "finished") do={
|
||||
# output=user-with-headers returns ALL response headers as one flat string,
|
||||
# ";"-separated, e.g. "Name: value;Name: value;...". There is no keyed
|
||||
# lookup, so we substring-match. Two pitfalls this handles:
|
||||
# - Header NAME case is not guaranteed (HTTP/2 lowercases names; header
|
||||
# names are case-insensitive anyway) -> lowercase the blob first.
|
||||
# - Some header VALUES contain ";" (e.g. strict-transport-security:
|
||||
# "max-age=...; includeSubDomains"). We anchor on the digest key and
|
||||
# read to the next ";"; the digest value (sha256:<hex>) has no ";",
|
||||
# so this is safe.
|
||||
:local rh [:convert transform=lc ($mr->"http-headers")]
|
||||
:local key "docker-content-digest: "
|
||||
:local p [:find $rh $key]
|
||||
:if ([:typeof $p] != "nil") do={
|
||||
:local rest [:pick $rh ($p + [:len $key]) [:len $rh]]
|
||||
:local q [:find $rest ";"]
|
||||
:if ([:typeof $q] = "nil") do={ :set q [:len $rest] }
|
||||
:set newDigest [:pick $rest 0 $q]
|
||||
}
|
||||
}
|
||||
} do={
|
||||
:log error "$scriptName: manifest fetch failed: $e"
|
||||
:error "manifest fetch failed"
|
||||
}
|
||||
:if ([:len $newDigest] = 0) do={
|
||||
:log error "$scriptName: could not read Docker-Content-Digest"
|
||||
:error "no digest"
|
||||
}
|
||||
:log info "$scriptName: registry :stable digest = $newDigest"
|
||||
|
||||
# --- 3. Compare with the last-deployed digest --------------------------------
|
||||
:local oldDigest ""
|
||||
:if ([:len [/file find where name=$stateFile]] > 0) do={
|
||||
:set oldDigest [/file get [/file find where name=$stateFile] contents]
|
||||
}
|
||||
|
||||
:if ($newDigest = $oldDigest) do={
|
||||
:log info "$scriptName: image unchanged; nothing to do"
|
||||
:error "noop"
|
||||
}
|
||||
:log info "$scriptName: image changed ($oldDigest -> $newDigest); updating"
|
||||
|
||||
# --- 4. Recreate the container -----------------------------------------------
|
||||
:local cid [/container find where name=$cName]
|
||||
:if ([:len $cid] > 0) do={
|
||||
:log info "$scriptName: stopping and removing existing container"
|
||||
:onerror e in={ /container stop $cid } do={ :log warning "$scriptName: stop: $e" }
|
||||
# Retry the REMOVE itself until it succeeds (up to ~30s). /container/remove
|
||||
# errors while the container is still running, so retrying the remove is
|
||||
# self-correcting: it waits for the stop to settle without us having to know
|
||||
# the exact status string. On success :retry stops; on persistent failure the
|
||||
# do={} block runs.
|
||||
:onerror e in={
|
||||
:retry command={ /container remove $cid } delay=1 max=30
|
||||
} do={
|
||||
:log error "$scriptName: remove failed after retries: $e"
|
||||
:error "remove failed"
|
||||
}
|
||||
}
|
||||
|
||||
# Pull happens implicitly on add when remote-image is given.
|
||||
:log info "$scriptName: adding new container from $imageRef"
|
||||
:onerror e in={
|
||||
/container add \
|
||||
remote-image=$imageRef \
|
||||
interface=$cInterface \
|
||||
root-dir=$cRootDir \
|
||||
mountlists=$cMountList \
|
||||
logging=$cLogging \
|
||||
start-on-boot=$cStartOnBoot \
|
||||
name=$cName
|
||||
} do={
|
||||
:log error "$scriptName: container add failed: $e"
|
||||
:error "add failed"
|
||||
}
|
||||
|
||||
# Start the container. After /container/add the image is still extracting, and
|
||||
# /container/start errors until extraction finishes, so we retry the START
|
||||
# itself (up to ~4min) — self-correcting, no need to poll an exact status
|
||||
# string. (If start-on-boot causes RouterOS to auto-start it once extraction
|
||||
# completes, a later manual start simply errors and :retry stops once it's
|
||||
# running / the do={} block runs.)
|
||||
:local ncid [/container find where name=$cName]
|
||||
:onerror e in={
|
||||
:retry command={ /container start $ncid } delay=2 max=120
|
||||
} do={
|
||||
:log warning "$scriptName: container start did not succeed within timeout (may still be extracting or already running): $e"
|
||||
}
|
||||
|
||||
# --- 5. Persist the new digest so we don't update again next run -------------
|
||||
# We record the digest once the new container exists. Even if the start above
|
||||
# is still settling, the container is created from the new image, so we should
|
||||
# not re-pull on the next run.
|
||||
:if ([:len [/file find where name=$stateFile]] > 0) do={
|
||||
/file set [/file find where name=$stateFile] contents=$newDigest
|
||||
} else={
|
||||
/file add name=$stateFile contents=$newDigest
|
||||
}
|
||||
:log info "$scriptName: updated to $newDigest"
|
||||
|
||||
# =============================================================================
|
||||
# SCHEDULING (after creating the named script per INSTALL step 2 above)
|
||||
# =============================================================================
|
||||
# Create a scheduler entry that runs the named script daily:
|
||||
#
|
||||
# /system/scheduler add name=update-tailscale interval=1d \
|
||||
# on-event="/system/script run update-tailscale" \
|
||||
# comment="Check for mikrotik-tailscale image updates"
|
||||
#
|
||||
# Adjust interval to taste (e.g. 6h, 1d, 7d). The check is cheap (one small
|
||||
# HTTPS request); it only pulls/recreates when the :stable digest changed.
|
||||
#
|
||||
# To test once, by hand:
|
||||
# /system/script run update-tailscale
|
||||
# =============================================================================
|
||||
Reference in New Issue
Block a user