26 Commits

Author SHA1 Message Date
Renovate Bot 712b1d0004 chore(deps): update renovate/renovate docker tag to v43.209.1
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-02 21:12:26 +00:00
Lumpiasty 6e5004aa0e Merge pull request 'chore(deps): update renovate/renovate docker tag to v43.207.4' (#10) from renovate/renovate-renovate-43.x into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
Reviewed-on: #10
2026-06-02 15:25:55 +00:00
Lumpiasty 57df037137 Merge pull request 'Fix tailscale up by building ipnbus and enable ip forwarding in entrypoint' (#11) from fix/forwarding-and-ipnbus into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #11
2026-06-02 14:15:40 +00:00
Lumpiasty 315fd630e3 enable IP forwarding via entrypoint (fixes IPv6 subnet routes)
ci/woodpecker/pr/pr-build Pipeline was successful
tailscaled does not reliably enable IPv6 forwarding inside a container
network namespace ('IPv6 forwarding is disabled'), so advertised IPv6
subnet routes silently fail. Add a tiny entrypoint.sh that sets
net.ipv4.ip_forward and net.ipv6.conf.all.forwarding (writable inside a
RouterOS container netns), then exec's tailscaled. Built in the builder
stage so it stays in the single /usr/local/bin COPY layer.

Verified: privileged run flips v6 forwarding 0->1 and exec's tailscaled
with CMD args intact.
2026-06-02 16:06:10 +02:00
Lumpiasty 1bc10bcb6e include ipnbus so 'tailscale up' waits and prints login URL
Without ipnbus, 'tailscale up' fires config at the daemon and returns
immediately ('built with ts_omit_ipnbus; not waiting for completion')
without printing the auth URL or confirming success. Add it to the
allowlist so interactive 'up' behaves normally.
2026-06-02 15:54:52 +02:00
Renovate Bot 745075f38c chore(deps): update renovate/renovate docker tag to v43.207.4
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-02 02:01:04 +00:00
Lumpiasty 9ff1623958 Merge pull request 'Refactor of docs' (#9) from refac/readme-cleanup into main
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Reviewed-on: #9
2026-06-01 18:41:46 +00:00
Lumpiasty 94427bd3f4 Merge pull request 'chore(deps): update renovate/renovate docker tag to v43.205.3' (#7) from renovate/renovate-renovate-43.x into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Reviewed-on: #7
2026-06-01 18:27:32 +00:00
Lumpiasty 37938ac471 Merge pull request 'chore(deps): update alpine/git docker tag to v2.52.0' (#6) from renovate/alpine-git-2.x into main
ci/woodpecker/push/release-tag Pipeline is pending
ci/woodpecker/push/pr-build Pipeline was canceled
Reviewed-on: #6
2026-06-01 18:27:24 +00:00
Lumpiasty 2ce364ea15 Merge pull request 'chore(deps): update alpine docker tag to v3.23.4' (#5) from renovate/alpine-3.x into main
ci/woodpecker/push/release-tag Pipeline is pending
ci/woodpecker/push/pr-build Pipeline was canceled
Reviewed-on: #5
2026-06-01 18:27:07 +00:00
Lumpiasty 3057685588 Merge pull request 'chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v6' (#8) from renovate/woodpeckerci-plugin-docker-buildx-6.x into main
ci/woodpecker/push/release-tag Pipeline is pending
ci/woodpecker/push/pr-build Pipeline was canceled
Reviewed-on: #8
2026-06-01 18:27:02 +00:00
Lumpiasty 3cf6a1faab Manual refactor of docs
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-01 20:23:28 +02:00
Renovate Bot 43ed7efe98 chore(deps): update renovate/renovate docker tag to v43.205.3
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-01 02:01:06 +00:00
Renovate Bot d45799a314 chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v6
ci/woodpecker/pr/pr-build Pipeline was successful
2026-05-30 02:04:22 +00:00
Renovate Bot a1da2564fd chore(deps): update alpine/git docker tag to v2.52.0
ci/woodpecker/pr/pr-build Pipeline was successful
2026-05-29 14:30:02 +00:00
Renovate Bot 9788fe146b chore(deps): update alpine docker tag to v3.23.4
ci/woodpecker/pr/pr-build Pipeline was successful
2026-05-29 14:29:59 +00:00
Lumpiasty f69263c480 Merge pull request 'test pr-build' (#4) from test/pr-build-trigger into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Reviewed-on: #4
2026-05-29 14:25:00 +00:00
Lumpiasty ae8c114109 trigger pr-build
ci/woodpecker/pr/pr-build Pipeline was successful
2026-05-29 16:11:03 +02:00
Lumpiasty ea0d90d8f0 automerge tailscale + component patch updates behind a PR build
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
Add .woodpecker/pr-build.yaml: builds all three arches (dry-run, no push)
on PRs and pushes to main, reporting status to Gitea. This is the gate
for automerge.

renovate.json automerge rules (platformAutomerge, merged only after the
PR build passes):
- tailscale stable patch AND minor
- Go/Alpine/busybox PATCH only
- base-image digest refreshes
Minor/major of build deps and tooling stay manual.

Move pinDigests into a dockerfile packageRule (top-level dockerfile.* is
deprecated). Document the automerge policy and its caveat (PR build proves
build-only, not runtime) in DESIGN.md.
2026-05-29 15:49:47 +02:00
Lumpiasty 7d1b9f99a5 correct extracted-size measurement guidance
ci/woodpecker/push/release-tag Pipeline was successful
The ~7 MB seen via 'du' inside the container is RouterOS block-allocation
rounding (a 3 MB file occupies ~6 MB of blocks), NOT layer duplication —
verified: the published image carries tailscale.combined in exactly one
layer, and the real flash cost is ~3.7 MiB (free-hdd-space delta).

Fix the docs to measure on-flash footprint via free-hdd-space delta, not
du; clarify the overlayfs section is about keeping the image clean (still
valid best practice) and explicitly decouple it from the du number.
2026-05-29 04:49:54 +02:00
Lumpiasty 7a6efb52ec include unixsocketidentity feature (fixes CLI access denied)
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
The --extra-small baseline omits unixsocketidentity, but without it the
localapi cannot verify a request came over the trusted unix socket, so
PermitRead/PermitWrite are always false and every CLI call (status, up,
set, ...) returns 'access denied' (tailscale/tailscale#17873). Add it to
the opt-in allowlist. Negligible size cost (~3.55 MB unchanged); the CLI
is non-functional without it.
2026-05-29 04:33:02 +02:00
Lumpiasty e0cbaee48b split docs into README + USAGE/DEVELOPMENT/DESIGN
ci/woodpecker/push/release-tag Pipeline was successful
README shrinks to a repo intro with pointers. Separate the three
audiences:
- docs/USAGE.md      deploy the prebuilt image on RouterOS + operate it
- docs/DEVELOPMENT.md build, local test, version bump, cut releases
- docs/DESIGN.md     size optimizations, feature allowlist, why the
                     updater and netmap disk-cache are removed, flash-wear
                     protection, versioning/release architecture, the
                     overlayfs layer-duplication gotcha, dependency pinning
2026-05-29 04:24:12 +02:00
Lumpiasty 01057e78b8 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)
2026-05-29 04:24:03 +02:00
Lumpiasty f7ddd164b3 drop cachenetmap feature, expand omission rationale
cachenetmap only persists the netmap to disk for cold-start-during-
control-outage; the in-memory map (the common case) is unaffected. Its
cost is a flash write on every netmap delta, which is frequent on active
tailnets — the opposite of this image's flash-conservation goal. Remove
it from the allowlist. Also expand the clientupdate/cachenetmap comments
to document why they're deliberately omitted, and fix the stale volume/
statedir comments that referenced the now-removed on-disk netmap cache.
2026-05-29 04:23:51 +02:00
Lumpiasty 082703a6b8 fix overlayfs layer duplication doubling extracted size
Creating the tailscale argv[0] symlinks with RUN in the final scratch
stage forced overlayfs to copy-up the whole /usr/local/bin directory,
duplicating the ~3 MB binary into a second layer. RouterOS extracts
overlay layers separately, so the on-disk rootfs measured ~7 MB instead
of ~3.4 MB. Assemble /usr/local/bin in the builder stage and bring it in
with a single COPY layer. Verified on RouterOS 7.21.2: du -sx / now ~3.4 MB.
2026-05-29 04:23:41 +02:00
Lumpiasty 864859a5a0 invalidate openbao token at the end of ci run
ci/woodpecker/push/release-tag Pipeline was successful
2026-05-29 01:07:06 +02:00
11 changed files with 1167 additions and 388 deletions
+26
View File
@@ -0,0 +1,26 @@
# Build validation for pull requests (and pushes to main).
#
# Builds the full multi-arch image but does NOT push it anywhere — it only
# proves the Dockerfile still builds for every supported architecture. This is
# the gate Renovate automerge waits on: a dependency bump that breaks the build
# fails this check and will NOT be automerged (and therefore never reaches
# :stable or the routers).
#
# Reports pass/fail status back to Gitea, so it shows up as a required check on
# the PR.
when:
- event: pull_request
- event: push
branch: main
steps:
- name: Build all arches (no push)
image: woodpeckerci/plugin-docker-buildx:6.1.0
privileged: true
settings:
repo: mikrotik-tailscale
platforms: linux/amd64,linux/arm64,linux/arm/v7
dry-run: true
build_args:
- OCI_VERSION=ci-${CI_COMMIT_SHA}
+8 -1
View File
@@ -34,7 +34,7 @@ steps:
- bao kv get -mount secret -field RENOVATE_TOKEN renovate > /woodpecker/git_token
- name: Auto-tag mt.1 on Tailscale bump
image: alpine/git:2.49.1
image: alpine/git:v2.52.0
environment:
CI_REPO_URL: https://gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale.git
commands:
@@ -61,3 +61,10 @@ steps:
- git tag -a "$TAG" -m "Automated release for Tailscale $TS"
- git push "https://woodpecker:$GIT_TOKEN@gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale.git" "$TAG"
- echo "Pushed $TAG"
- name: Invalidate OpenBao token
image: quay.io/openbao/openbao:2.5.4
environment:
VAULT_ADDR: https://openbao.lumpiasty.xyz:8200
commands:
- export VAULT_TOKEN=$(cat /woodpecker/.vault_id)
- bao write -f auth/token/revoke-self
+8 -1
View File
@@ -43,7 +43,7 @@ steps:
- 'printf "PLUGIN_PASSWORD=%s\n" "$(bao kv get -mount secret -field REGISTRY_PASSWORD container-registry)" >> /woodpecker/registry.env'
- name: Build and push multi-arch image
image: woodpeckerci/plugin-docker-buildx:5.2.2
image: woodpeckerci/plugin-docker-buildx:6.1.0
privileged: true
settings:
registry: gitea.lumpiasty.xyz
@@ -56,3 +56,10 @@ steps:
- OCI_VERSION=${CI_COMMIT_TAG}
# Credentials (PLUGIN_USERNAME / PLUGIN_PASSWORD) come from OpenBao.
env_file: /woodpecker/registry.env
- name: Invalidate OpenBao token
image: quay.io/openbao/openbao:2.5.4
environment:
VAULT_ADDR: https://openbao.lumpiasty.xyz:8200
commands:
- export VAULT_TOKEN=$(cat /woodpecker/.vault_id)
- bao write -f auth/token/revoke-self
+1 -1
View File
@@ -46,7 +46,7 @@ steps:
- bao kv get -mount secret -field GITHUB_COM_TOKEN renovate > /woodpecker/github_com_token
- name: renovate
# Renovate's built-in "woodpecker" manager tracks this image automatically.
image: renovate/renovate:43.194.0
image: renovate/renovate:43.209.1
environment:
# --- platform / target ---
RENOVATE_PLATFORM: gitea
+76 -18
View File
@@ -63,12 +63,40 @@ WORKDIR /src/tailscale
# portmapper — NAT-PMP / PCP / UPnP to punch through upstream NAT
# listenrawdisco — raw sockets for more robust disco/NAT-traversal
# health — health subsystem required by 'tailscale status'
# cachenetmap — cache netmap on disk for faster reconnect after reboot
# IMPORTANT: mount cache dir on tmpfs, not internal flash
# iptables — Linux iptables support for routing rules
# unixsocketidentity — REQUIRED for the CLI to talk to the daemon. Without it,
# the localapi can't verify a request arrived over the
# trusted unix socket, so PermitRead/PermitWrite are
# always false and EVERY CLI call (status, up, set, ...)
# returns "access denied" (tailscale/tailscale#17873).
# ipnbus — IPN bus watch. Without it, 'tailscale up' cannot wait
# for completion: it fires config at the daemon and
# returns immediately ("built with ts_omit_ipnbus; not
# waiting for completion") WITHOUT printing the auth URL
# or confirming success. Including it makes interactive
# 'up' behave normally (blocks, prints login URL).
#
# Everything else remains omitted, including (rationale):
# clientupdate — updates managed via Docker image rebuild
# clientupdate — DELIBERATELY removed. The built-in updater would download
# the FULL official upstream tailscale binary (tens of MB,
# with all features) directly onto the device, defeating the
# entire point of this minimal build and risking filling the
# 16 MB flash. It also can't update a binary baked into a
# read-only container image. Updates are instead delivered by
# rebuilding/republishing this image (CI) and pulling the new
# image only when it actually changed (see the RouterOS
# update script). This keeps the on-device footprint minimal
# and the update path controlled, reproducible, and flash-safe.
# cachenetmap — DELIBERATELY omitted. It ONLY persists the netmap to disk so
# the node can come online from the last-known config after a
# COLD START while the control plane is simultaneously
# unreachable. The in-memory netmap is NOT gated by this tag:
# a running daemon that loses its control connection keeps its
# map and can still reach known peers (data path is direct
# WireGuard/DERP, not via control). The only loss is the narrow
# reboot-during-control-outage case. In exchange we avoid disk
# writes on every netmap delta (frequent on busy tailnets),
# which is exactly the flash wear we want to avoid.
# logtail — no persistent log writes to flash; also pass
# --no-logs-no-support at runtime
# netstack+gro — userspace networking; router uses kernel TUN
@@ -87,8 +115,9 @@ RUN mkdir -p /out && \
-e 's/ts_omit_portmapper,\{0,1\}//g' \
-e 's/ts_omit_listenrawdisco,\{0,1\}//g' \
-e 's/ts_omit_health,\{0,1\}//g' \
-e 's/ts_omit_cachenetmap,\{0,1\}//g' \
-e 's/ts_omit_iptables,\{0,1\}//g' \
-e 's/ts_omit_unixsocketidentity,\{0,1\}//g' \
-e 's/ts_omit_ipnbus,\{0,1\}//g' \
-e 's/,$//' \
) && \
echo "Build tags: ${TAGS}" && \
@@ -117,6 +146,35 @@ RUN mkdir -p /out && \
# Expected: ~14 MB raw → ~3.8 MB compressed (with -gcflags=all=-l)
RUN upx --lzma --best /out/tailscale.combined
# Lay out the final /usr/local/bin HERE (binary + argv[0] symlinks) so the final
# stage can bring it in with a SINGLE COPY layer. Creating the symlinks with a
# `RUN` in the final scratch stage instead would force overlayfs to copy-up the
# whole directory — duplicating the ~3 MB binary into another layer and roughly
# doubling the extracted on-disk size on RouterOS (overlay layers are extracted
# separately). Building it in one place keeps it to one copy.
RUN mkdir -p /out/usrlocalbin && \
mv /out/tailscale.combined /out/usrlocalbin/tailscale.combined && \
ln -s /usr/local/bin/tailscale.combined /out/usrlocalbin/tailscale && \
ln -s /usr/local/bin/tailscale.combined /out/usrlocalbin/tailscaled
# Entrypoint wrapper: enable IP forwarding inside the container's network
# namespace, then exec tailscaled. tailscaled does NOT reliably enable IPv6
# forwarding itself in a container netns ("IPv6 forwarding is disabled" warning),
# which silently breaks advertised IPv6 subnet routes. The sysctls ARE writable
# from inside a RouterOS container, so we set both here. Written in the builder
# stage so it ships in the same single /usr/local/bin COPY layer (preserves the
# overlayfs single-copy property). `exec` keeps tailscaled as PID 1.
RUN printf '%s\n' \
'#!/bin/sh' \
'# Enable IPv4/IPv6 forwarding. Required for advertised subnet routes and' \
'# exit-node functionality.' \
'for f in /proc/sys/net/ipv4/ip_forward /proc/sys/net/ipv6/conf/all/forwarding; do' \
' if [ -w "$f" ]; then echo 1 > "$f" 2>/dev/null || echo "warn: could not write $f"; fi' \
'done' \
'exec /usr/local/bin/tailscaled "$@"' \
> /out/usrlocalbin/entrypoint.sh && \
chmod +x /out/usrlocalbin/entrypoint.sh
# =============================================================================
# Stage 2: Custom minimal busybox
# =============================================================================
@@ -138,7 +196,7 @@ RUN upx --lzma --best /out/tailscale.combined
# This stage runs on the TARGET platform (no --platform override): gcc then
# produces native target-arch binaries directly. Under buildx this is
# transparently emulated via binfmt/QEMU for non-native targets.
FROM alpine:3.21.7@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d AS busybox
FROM alpine:3.23.4@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11 AS busybox
# renovate: datasource=docker depName=busybox versioning=docker
ARG BUSYBOX_VERSION=1.37.0
@@ -211,12 +269,10 @@ COPY --from=busybox /rootfs/ /
# CA certificates (needed to reach Tailscale coordination server)
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Combined Tailscale binary
COPY --from=builder /out/tailscale.combined /usr/local/bin/tailscale.combined
# Symlinks: combined binary behavior switches on argv[0]
RUN ["/bin/busybox", "ln", "-s", "/usr/local/bin/tailscale.combined", "/usr/local/bin/tailscale"]
RUN ["/bin/busybox", "ln", "-s", "/usr/local/bin/tailscale.combined", "/usr/local/bin/tailscaled"]
# Combined Tailscale binary + its argv[0] symlinks, in a single layer (built in
# the builder stage to avoid overlayfs copy-up duplicating the binary — see the
# builder stage comment).
COPY --from=builder /out/usrlocalbin/ /usr/local/bin/
# Ensure /usr/local/bin and busybox dirs are on PATH for interactive shells
ENV PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
@@ -226,18 +282,19 @@ ENV PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
#
# /var/lib/tailscale — persistent state (authkey, node identity)
# → bind-mount to MikroTik disk storage
# → survives reboots, written infrequently
#
# /var/lib/tailscale/cache — netmap cache (cachenetmap feature)
# → mount as tmpfs so it never touches flash
# → speeds up reconnect but is recreatable
# → survives reboots, written infrequently (only on
# auth / key rotation / prefs change)
# → netmap is NOT cached to disk (cachenetmap is
# omitted), so this dir sees no per-netmap writes
#
# /var/run/tailscale — runtime socket dir
# → tmpfs, lost on reboot (expected)
# -----------------------------------------------------------------------------
VOLUME ["/var/lib/tailscale"]
ENTRYPOINT ["/usr/local/bin/tailscaled"]
# entrypoint.sh enables IP forwarding (incl. IPv6) in the container netns, then
# exec's tailscaled with the CMD flags below as its arguments.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# Default flags:
# --no-logs-no-support disables logtail uploads (logtail binary code is
@@ -246,7 +303,8 @@ ENTRYPOINT ["/usr/local/bin/tailscaled"]
# to write log files)
# --state persistent node identity / authkey storage
# --socket CLI communication socket (on tmpfs)
# --statedir where cache and other runtime files land
# --statedir var root (derpmap cache, certs, etc.); no netmap
# disk cache here since cachenetmap is omitted
CMD ["--no-logs-no-support", \
"--state=/var/lib/tailscale/tailscaled.state", \
"--socket=/var/run/tailscale/tailscaled.sock", \
+55 -363
View File
@@ -7,6 +7,45 @@ A minimal Tailscale Docker image built for MikroTik routers running
16 MB internal flash. Built from source with only router-relevant features
included.
> Disclaimer: This project has been largely vibe-coded, but I stand behind design and implementation choices made.
- **~4 MB** extracted rootfs (`FROM scratch` + UPX'd Tailscale binary + a custom
static busybox debug shell).
- **Multi-arch**: amd64, arm64, arm/v7 — one tag, RouterOS pulls the right one.
- **No built-in updater** (it would pull the full upstream binary and wear
flash); updates are delivered by CI and pulled only when the image actually
changed.
- **Flash-wear conscious**: minimal persistent state, no netmap disk-caching,
tmpfs for scratch and runtime.
## Motivation
There is no built-in Tailscale integration in MikroTik, and other solutions
feel underwhelming. I've used Fluent-networks' tailscale-mikrotik until now,
but that basically forced me to connect external storage to my router
just to use Tailscale. This approach, while works, is fragile, wasteful
and overcomplicated, so I decided to do better one myself.
| | **This project** | Fluent-networks/tailscale-mikrotik |
|---|---|---|
| Size | **~4 MB** | ~106 MB |
| Size reduction technique | **Minimal container with custom Tailscale and Busybox builds, compressed by UPX** | Alpine Linux base, Tailscale binary compressed by UPX on build, but auto-update completely nullifies that on first launch |
| Update mechanism | **Automatically released optimized container images with new Tailscale versions, scheduled script updating deployment on new version** | None, opt-in Tailscale built-in auto-update downloading official binaries |
| Flash wear | **Write-heavy functionality compiled out, suitable for low-endurance flash chips** | High, constant netmap cache updates |
| Stability | **Immutable container** | Tailscale app can update on its own |
| Features | **Only router-useful Tailscale features compiled, Busybox providing shell and utils** | Full tailscale, OpenSSH server, Bash, IPTables |
## Documentation
- **[Usage](docs/USAGE.md)** — deploy the published image on a MikroTik router
and operate it (networking, auth, MagicDNS, automatic updates). Start here if
you just want it running.
- **[Development](docs/DEVELOPMENT.md)** — build the image, test it locally, bump
the Tailscale version, and cut releases.
- **[Design & rationale](docs/DESIGN.md)** — size optimizations, the feature
allowlist, why certain features are deliberately removed, flash-wear
protection, and the versioning / release / update architecture.
## Supported architectures
| Docker platform | RouterOS arch | Example devices |
@@ -15,371 +54,24 @@ included.
| `linux/arm64` | arm64 | RB5009, CCR2004/2116/2216, hAP ax³, L009, Chateau |
| `linux/arm/v7` | arm (ARMv7) | hAP ac², RB3011, RB4011, RB1100AHx4 |
A single Dockerfile builds all three. The Go binary is **cross-compiled** (the
builder stage runs natively on the host for speed), while the busybox stage and
final image are built for the target platform (via `buildx` + QEMU/binfmt for
non-native targets).
ARMv5 (hEX Refresh / hAP ax S) is **not** supported — see
[DESIGN.md](docs/DESIGN.md#architecture-support).
**ARMv5 is not supported** (hEX Refresh / hAP ax S, EN7562CT CPU — RouterOS
calls these `arm32v5`). ARMv5 has no Alpine/musl base image, so it cannot use
this image's musl + `scratch` design; it would require a glibc (Debian) base
and produce a substantially larger image (~50 MB+ vs ~4 MB). If you need it,
that's a separate build, not just a `--platform` change.
## Quick start
## Image size
- **Run it on a router:** follow **[docs/USAGE.md](docs/USAGE.md)** — it deploys
the prebuilt image, no build needed.
- **Build it yourself:** `./build.sh` (needs docker buildx + QEMU for
cross-arch); details in **[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)**.
On-disk footprint once extracted (this is what matters — RouterOS stores the
**extracted** rootfs on disk via overlayfs, not the compressed layers):
## Repository layout
| Component | On-disk size |
| Path | Purpose |
|---|---|
| tailscale.combined (UPX-compressed) | ~3.84 MB |
| custom static busybox (UPX, ~100 applets) | ~229 kB |
| CA certificates | ~218 kB |
| **Total extracted rootfs** | **~4.1 MB** |
(The compressed image / transfer tarball is ~4.3 MB.)
The binary is built with Tailscale's `--extra-small` feature tag set as the
baseline. Features are opted in explicitly — any new feature Tailscale adds
in a future release stays omitted until deliberately added to the Dockerfile.
### Size optimizations applied
- **Feature allowlist** (`--extra-small` baseline + ~10 opt-ins) keeps the
binary minimal and forward-safe against new Tailscale features.
- **`-gcflags=all=-l`** disables function inlining across all packages,
shrinking the compressed binary by ~600 kB. Inlining is a performance
optimization only; disabling it does not affect correctness. The CPU cost
is negligible for an I/O-bound router daemon.
- **`-ldflags="-s -w"`** strips the symbol table and DWARF debug info.
- **`-trimpath`** removes local filesystem paths from the binary.
- **UPX `--lzma --best`** compresses the Tailscale binary (~14 MB → ~3.8 MB).
- **Custom static busybox** — instead of the official `busybox:musl` image
(all ~404 applets, ~1.24 MB), a static busybox is built from source with
only ~100 curated applets (~420 kB), then UPX-compressed to ~229 kB on
disk. The applet set is defined in
[`busybox-applets.config`](busybox-applets.config).
**busybox UPX requires care.** UPX normally breaks busybox's standalone
applet dispatch: the ash shell re-execs `/proc/self/exe` to run built-in
applets, and UPX breaks that path so typed commands fail
([upx#248](https://github.com/upx/upx/issues/248), closed as "invalid").
We work around it by building **without** the standalone/nofork features
and providing an explicit `/bin/<applet>` symlink farm. Commands then
resolve via the normal `PATH` → symlink → `argv[0]` dispatch, which works
under UPX. The cost is a `fork+exec` per command instead of a nofork
internal call — fine for an occasional debug shell.
Because RouterOS stores the extracted rootfs on disk, UPX'ing busybox
saves a real ~195 kB of flash (424 kB → 229 kB), not just transfer size.
The final image is built `FROM scratch` — there is no base distro layer.
It contains only the busybox binary + applet symlinks, the CA bundle, and
the Tailscale binary.
## Features included
| Feature | Why |
|---|---|
| `advertise-exit-node` | Run the router as a Tailscale exit node |
| `advertise-routes` | Expose LAN subnets to the tailnet |
| `use-exit-node` | Route the router's own traffic via a remote exit node |
| `accept-routes` | Receive subnet routes from other tailnet nodes |
| DNS / MagicDNS | Resolve `*.ts.net` names (see DNS section below) |
| portmapper (NAT-PMP/PCP/UPnP) | Punch through upstream NAT |
| listenrawdisco | Raw socket disco for better NAT traversal |
| health | Powers `tailscale status` output |
| cachenetmap | Cache network map for faster reconnect after reboot |
| iptables | Linux iptables support for routing rules |
| osrouter | Configure kernel network stack and routing tables |
## Features intentionally omitted
| Feature | Reason |
|---|---|
| `clientupdate` | Updates are managed by rebuilding the Docker image |
| `logtail` | Would attempt persistent log writes; wear flash |
| `netlog` | Network flow logging; separate concern |
| `netstack` + `gro` | Userspace/gVisor networking; router uses kernel TUN |
| `ssh` | Access via MikroTik SSH + `tailscale` CLI instead |
| `linuxdnsfight` | inotify on `/etc/resolv.conf`; no systemd in container |
| `networkmanager` / `resolved` / `dbus` / `sdnotify` | No systemd stack in container |
| `drive` / `taildrop` / `webclient` | Not useful on a headless router |
| All GUI / desktop / cloud / k8s features | Irrelevant |
## Volume layout
Three mount points, with different persistence requirements:
```
/var/lib/tailscale persistent — node identity, auth state
bind-mount to MikroTik disk storage
written rarely (only on auth / key rotation)
/var/lib/tailscale/cache ephemeral — netmap cache
mount as tmpfs to avoid flash writes
recreated automatically on next connect
/var/run/tailscale ephemeral — daemon Unix socket
mount as tmpfs
lost on reboot, recreated on start
```
Keeping the cache and socket directories on tmpfs prevents unnecessary
flash wear while still allowing fast reconnect after reboot (the cache
is repopulated from the Tailscale coordination server on first connect).
## Building
### All architectures at once
Use the helper script (requires `docker buildx` + QEMU/binfmt for non-native
targets):
```sh
# One-time: register emulators for cross-arch builds
docker run --privileged --rm tonistiigi/binfmt --install arm64,arm
# Build all arches and load into local docker
./build.sh
# Build all arches and also export per-arch tarballs into ./dist/
./build.sh --tar
# Build a single arch
./build.sh arm64
./build.sh --tar armv7
```
### Manual single-arch build
The architecture is selected via `buildx --platform`; the Dockerfile maps it to
the correct `GOARCH`/`GOARM` automatically:
```sh
docker buildx build --platform linux/arm64 --load -t mikrotik-tailscale:arm64 .
docker buildx build --platform linux/arm/v7 --load -t mikrotik-tailscale:armv7 .
docker buildx build --platform linux/amd64 --load -t mikrotik-tailscale:amd64 .
```
To build for a different Tailscale version, add:
```sh
--build-arg TAILSCALE_VERSION=v1.98.3
```
### Notes
- The Go builder cross-compiles natively (fast); only the busybox stage runs
under emulation for non-native targets.
- The build prints the resolved target and Go build tags, e.g.:
```
Cross-compiling: GOOS=linux GOARCH=arm64 GOARM=
Build tags: ts_include_cli,ts_omit_ace,ts_omit_acme,...
```
### Per-architecture image sizes
| Arch | Image |
|---|---|
| amd64 | ~4.2 MB |
| arm64 | ~3.5 MB |
| arm/v7 | ~3.5 MB |
## Running (local test)
```sh
# Create a volume for persistent state
docker volume create tailscale-state
# Start the daemon
docker run -d \
--name tailscale \
--cap-add NET_ADMIN \
--cap-add NET_RAW \
--device /dev/net/tun \
--tmpfs /var/lib/tailscale/cache \
--tmpfs /var/run/tailscale \
-v tailscale-state:/var/lib/tailscale \
mikrotik-tailscale
# Authenticate (opens browser / prints auth URL)
docker exec tailscale tailscale login
# Check status
docker exec tailscale tailscale status
# Advertise a subnet
docker exec tailscale tailscale set --advertise-routes=192.168.88.0/24
# Advertise as exit node
docker exec tailscale tailscale set --advertise-exit-node
```
Subnet routes and exit node advertisement must also be approved in the
[Tailscale admin console](https://login.tailscale.com/admin/machines).
## Unattended authentication
For automated / headless deployment, use an auth key:
```sh
docker exec tailscale tailscale up \
--authkey=tskey-auth-<key> \
--advertise-routes=192.168.88.0/24 \
--advertise-exit-node
```
Auth keys can be created in the Tailscale admin console under
**Settings → Keys**. Use a reusable key tagged with a device tag for
infrastructure nodes.
## MagicDNS
The binary includes DNS support but the daemon is started with
`--no-logs-no-support`, which does not affect DNS. To use MagicDNS name
resolution, configure MikroTik's DNS to forward `.ts.net` queries to
Tailscale's magic DNS resolver:
```
/ip dns static
add name="ts.net" type=FWD forward-to=100.100.100.100 match-subdomain=yes
```
This avoids writing to `/etc/resolv.conf` inside the container (which would
happen if `--accept-dns` is passed to `tailscale up`). The container resolves
Tailscale node names; the rest of the router uses its own DNS.
## Flash wear protection
Several measures are in place to avoid wearing out internal flash:
- `clientupdate` omitted from binary — no background update downloads
- `logtail` omitted from binary — no log upload attempts
- `--no-logs-no-support` passed to daemon — suppresses any remaining log
buffering
- `netmap` cache mounted on tmpfs — cache writes never reach flash
- `/var/run/tailscale` socket on tmpfs — runtime files never reach flash
- Only `/var/lib/tailscale/tailscaled.state` touches persistent storage,
and it is written only when the node authenticates or rotates its key
## Upgrading
Version bumps (Tailscale, busybox, base image digests) are normally proposed
automatically via Renovate — see
[Dependency pinning & automated updates](#dependency-pinning--automated-updates).
Merge the Renovate PR, then rebuild and redeploy.
The feature allowlist in the Dockerfile carries forward automatically across
Tailscale versions — any new `ts_omit_*` tags introduced in a new release will
be omitted by default.
To bump manually, edit `ARG TAILSCALE_VERSION` in the `Dockerfile` (so the pin
stays in version control) and rebuild:
```sh
./build.sh --tar # rebuild all arches at the pinned version
# or, override at build time without editing the Dockerfile:
docker buildx build --platform linux/arm64 \
--build-arg TAILSCALE_VERSION=v1.100.0 \
--load -t mikrotik-tailscale:arm64 .
```
## Versioning & releases
Released images are versioned as:
```
v<TAILSCALE_VERSION>-mt.<N>
```
e.g. `v1.98.3-mt.1`. The two parts mean:
- **`v<TAILSCALE_VERSION>`** — the bundled Tailscale version (the "what's
inside" identifier), taken from `ARG TAILSCALE_VERSION` in the Dockerfile.
- **`mt.<N>`** — the local revision. It only changes on a *meaningful* release,
never on a build-system-only rebuild.
### When a release happens
| Trigger | Result |
|---|---|
| Renovate bumps `TAILSCALE_VERSION` (merged to `main`) | CI **auto-creates** git tag `v<new>-mt.1` → image published |
| You make a meaningful fix/change on the current Tailscale version | **You** create the next tag manually (`v<ts>-mt.2`, `mt.3`, …) → image published |
| Dependency-only bump (Go / Alpine / busybox / Dockerfile syntax) | **No release.** Rides along with the next Tailscale bump or manual tag |
So routers only ever see a new release for Tailscale bumps or your deliberate
fixes — build-system churn doesn't trigger updates.
Each published image is stamped with `org.opencontainers.image.version` equal to
its full tag; this is the value the MikroTik update job compares against the
registry to decide whether to recreate the container.
### How it's wired (Woodpecker)
- **`.woodpecker/release-tag.yaml`** — on push to `main`, parses
`TAILSCALE_VERSION`; if no `v<ts>-mt.*` tag exists yet, creates and pushes
`v<ts>-mt.1` (using the Gitea token from OpenBao). It never creates `mt.2+`.
- **`.woodpecker/release.yaml`** — on a `v*-mt.*` tag push, builds the
multi-arch manifest (amd64 + arm64 + arm/v7) and pushes it to
`gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale` as both `:<tag>` and
`:stable`. Registry creds come from OpenBao (`secret/container-registry`).
### Cutting a manual release
```sh
# fix something, commit to main, then:
git tag -a v1.98.3-mt.2 -m "Fix X"
git push origin v1.98.3-mt.2
```
The tag push triggers the build+publish automatically.
## Dependency pinning & automated updates
All upstream dependencies are version-pinned for reproducible builds:
All versions are fully qualified (no floating `major.minor` tags):
| Dependency | Where | Pinned form |
|---|---|---|
| Go toolchain | `Dockerfile` `FROM golang:…` | full version tag + `@sha256` digest |
| Alpine (busybox build base) | `Dockerfile` `FROM alpine:…` | full version tag + `@sha256` digest |
| Tailscale | `Dockerfile` `ARG TAILSCALE_VERSION` | full git release tag |
| busybox | `Dockerfile` `ARG BUSYBOX_VERSION` | full release version |
| Renovate / OpenBao | `.woodpecker/renovate.yaml` `image:` | full version tag |
Updates are proposed automatically by [Renovate](https://docs.renovatebot.com/),
run **self-hosted** from a Woodpecker cron pipeline (Woodpecker has no native
Renovate support):
- `renovate.json` — repository rules. All dependencies follow the latest
upstream releases (including major versions); each bump arrives as its own PR
that the multi-arch build validates before you merge. Base image tags also
get their `@sha256` digests refreshed via `pinDigests`. The one special rule:
- `tailscale` only follows **stable** releases — Tailscale uses even minor
versions for stable (`v1.98.x`) and odd for unstable (`v1.99.x`), so the
rule filters to even minors.
- `.woodpecker/renovate.yaml` — the scheduled job that runs `renovate/renovate`
against this repo.
```sh
# Renovate repo config
docker run --rm -e RENOVATE_CONFIG_TYPE=repo -v "$PWD":/work -w /work \
--entrypoint renovate-config-validator renovate/renovate
# Woodpecker pipeline
docker run --rm -v "$PWD":/work -w /work \
woodpeckerci/woodpecker-cli:v3 lint .woodpecker/renovate.yaml
```
## References
- [Tailscale: Smaller binaries for embedded devices](https://tailscale.com/docs/how-to/set-up-small-tailscale)
- [Renovate self-hosting](https://docs.renovatebot.com/getting-started/running/)
- [Woodpecker cron jobs](https://woodpecker-ci.org/docs/usage/cron)
- [MikroTik Container documentation](https://help.mikrotik.com/docs/display/ROS/Container)
- [Tailscale subnet routers](https://tailscale.com/kb/1019/subnets)
- [Tailscale exit nodes](https://tailscale.com/kb/1103/exit-nodes)
| `Dockerfile` | Multi-stage, multi-arch build (cross-compiled Go + custom busybox) |
| `busybox-applets.config` | Curated busybox applet set |
| `build.sh` | Build all/one arch, optionally export per-arch tarballs |
| `routeros/update-tailscale.rsc` | RouterOS auto-update script (digest compare + recreate) |
| `.woodpecker/` | CI: Renovate cron, release tagging, multi-arch publish |
| `renovate.json` | Dependency-update rules |
| `docs/` | Tutorial and design docs |
+390
View File
@@ -0,0 +1,390 @@
# Design & rationale
Why `mikrotik-tailscale` is built the way it is: size optimizations, the
feature allowlist, deliberate omissions, flash-wear protection, and the
versioning/release/update architecture.
For deployment, see [USAGE.md](USAGE.md); for building and releasing, see
[DEVELOPMENT.md](DEVELOPMENT.md).
## Image size
On-disk footprint once extracted (this is what matters — RouterOS stores the
**extracted** rootfs on disk via overlayfs, not the compressed layers).
Measured flattened rootfs for the arm64 image:
| Component | On-disk size |
|---|---|
| `tailscale.combined` (UPX-compressed) | ~2.98 MB |
| custom static busybox (UPX, ~100 applets) | ~218 kB |
| CA certificates | ~213 kB |
| **Total extracted rootfs** | **~3.4 MB** |
(The compressed image / transfer tarball is ~3.34.3 MB depending on arch.)
| Arch | Image (compressed) |
|---|---|
| amd64 | ~4.2 MB |
| arm64 | ~3.5 MB |
| arm/v7 | ~3.5 MB |
On a deployed RouterOS device the container consumes **~3.7 MiB of flash**
(measured by `free-hdd-space` delta). Note that `du` *inside* the container
reports roughly double that (~7 MB) — that is RouterOS block-allocation
rounding, **not** real usage or duplication; see
[Avoiding overlayfs layer duplication](#avoiding-overlayfs-layer-duplication)
for how to measure correctly.
The binary is built with Tailscale's `--extra-small` feature tag set as the
baseline. Features are opted in explicitly — any new feature Tailscale adds
in a future release stays omitted until deliberately added to the Dockerfile.
### Size optimizations applied
- **Feature allowlist** (`--extra-small` baseline + ~10 opt-ins) keeps the
binary minimal and forward-safe against new Tailscale features.
- **`-gcflags=all=-l`** disables function inlining across all packages,
shrinking the compressed binary by ~600 kB. Inlining is a performance
optimization only; disabling it does not affect correctness. The CPU cost
is negligible for an I/O-bound router daemon.
- **`-ldflags="-s -w"`** strips the symbol table and DWARF debug info.
- **`-trimpath`** removes local filesystem paths from the binary.
- **UPX `--lzma --best`** compresses the Tailscale binary (~14 MB → ~3.8 MB).
- **Custom static busybox** — instead of the official `busybox:musl` image
(all ~404 applets, ~1.24 MB), a static busybox is built from source with
only ~100 curated applets (~420 kB), then UPX-compressed to ~229 kB on
disk. The applet set is defined in
[`busybox-applets.config`](../busybox-applets.config).
**busybox UPX requires care.** UPX normally breaks busybox's standalone
applet dispatch: the ash shell re-execs `/proc/self/exe` to run built-in
applets, and UPX breaks that path so typed commands fail
([upx#248](https://github.com/upx/upx/issues/248), closed as "invalid").
We work around it by building **without** the standalone/nofork features
and providing an explicit `/bin/<applet>` symlink farm. Commands then
resolve via the normal `PATH` → symlink → `argv[0]` dispatch, which works
under UPX. The cost is a `fork+exec` per command instead of a nofork
internal call — fine for an occasional debug shell.
Because RouterOS stores the extracted rootfs on disk, UPX'ing busybox
saves a real ~195 kB of flash (424 kB → 229 kB), not just transfer size.
The final image is built `FROM scratch` — there is no base distro layer.
It contains only the busybox binary + applet symlinks, the CA bundle, the
Tailscale binary, and a tiny `entrypoint.sh`.
### Entrypoint: IP forwarding
`ENTRYPOINT` is a small `entrypoint.sh` that enables IPv4 and IPv6 forwarding
(`net.ipv4.ip_forward`, `net.ipv6.conf.all.forwarding`) in the container's
network namespace, then `exec`s `tailscaled` (so the daemon stays PID 1). This
is necessary because `tailscaled` does **not** reliably enable IPv6 forwarding
itself inside a container netns — it logs "IPv6 forwarding is disabled" and
advertised IPv6 subnet routes silently fail. The sysctls are writable from
inside a RouterOS container, so the entrypoint sets them directly; no
host-side or `/container` configuration is required. The script is created in
the builder stage so it ships in the same single `/usr/local/bin` `COPY` layer
(preserving the [single-copy property](#avoiding-overlayfs-layer-duplication)).
### Avoiding overlayfs layer duplication
Best practice for the final image: **don't run a `RUN` that mutates a directory
already populated by an earlier layer.** Each Dockerfile instruction is its own
layer; if `/usr/local/bin/` is created by a `COPY` (containing the ~3 MB
`tailscale.combined`) and a later `RUN ln -s …` adds a symlink *inside that same
directory*, overlayfs performs a **copy-up** of the entire directory — including
the 3 MB binary — into the new layer. The binary then physically exists in two
image layers.
The fix: assemble `/usr/local/bin/` completely in the **builder** stage (binary
+ both `argv[0]` symlinks) and bring it into the final image with a **single
`COPY` layer**, never mutating it afterwards. The Dockerfile does this; don't
reintroduce a post-`COPY` `RUN` against that path. You can confirm the published
image carries the binary in exactly one layer:
```
docker save <image> -o img.tar && tar xf img.tar -C img/
# then grep each blob layer for usr/local/bin/tailscale.combined — it must
# appear in exactly ONE layer.
```
Note: this is about keeping the *image* clean. It does **not** change what `du`
reports on the device — see the measurement note below.
To verify the on-flash footprint on a deployed router, use the **free-space
delta**, not `du`:
```
/system/resource/print # note free-hdd-space before and after adding the container
```
The container should consume **~3.7 MiB** of flash (e.g. 94.6 → 90.9 MiB free).
Do **not** trust `du` inside the container for this. Busybox `du` reports
*allocated blocks*, and RouterOS's container store rounds a ~3 MB file up to
~6 MB of blocks — so `du -sx /` reports ~7 MB even though real flash use is
~3.7 MB. `ls -la /usr/local/bin` confirms the binary's true content size
(~3.1 MB) and that it is a single file with two symlinks (no duplication).
The image itself carries the binary in exactly one layer (verified at the blob
level); the inflation is purely the filesystem's block accounting.
## Architecture support
A single Dockerfile builds all three supported RouterOS architectures. The Go
binary is **cross-compiled** (the builder stage runs natively on the host for
speed), while the busybox stage and final image are built for the target
platform (via `buildx` + QEMU/binfmt for non-native targets).
**ARMv5 is not supported** (hEX Refresh / hAP ax S, EN7562CT CPU — RouterOS
calls these `arm32v5`). ARMv5 has no Alpine/musl base image, so it cannot use
this image's musl + `scratch` design; it would require a glibc (Debian) base
and produce a substantially larger image (~50 MB+ vs ~4 MB). If you need it,
that's a separate build, not just a `--platform` change.
## Features included
| Feature | Why |
|---|---|
| `advertise-exit-node` | Run the router as a Tailscale exit node |
| `advertise-routes` | Expose LAN subnets to the tailnet |
| `use-exit-node` | Route the router's own traffic via a remote exit node |
| `accept-routes` | Receive subnet routes from other tailnet nodes |
| DNS / MagicDNS | Resolve `*.ts.net` names |
| portmapper (NAT-PMP/PCP/UPnP) | Punch through upstream NAT |
| listenrawdisco | Raw socket disco for better NAT traversal |
| health | Powers `tailscale status` output |
| iptables | Linux iptables support for routing rules |
| osrouter | Configure kernel network stack and routing tables |
| unixsocketidentity | **Required** — without it the localapi denies every CLI call with "access denied" ([tailscale#17873](https://github.com/tailscale/tailscale/issues/17873)) |
| ipnbus | Lets `tailscale up` wait for completion and print the login URL; without it `up` returns immediately without confirming success |
## Features intentionally omitted
| Feature | Reason |
|---|---|
| `clientupdate` | **Deliberately removed** — see [Why the built-in updater is removed](#why-the-built-in-updater-is-removed) |
| `cachenetmap` | **Deliberately removed** — see [Why netmap disk-caching is removed](#why-netmap-disk-caching-is-removed) |
| `logtail` | Would attempt persistent log writes; wear flash |
| `netlog` | Network flow logging; separate concern |
| `netstack` + `gro` | Userspace/gVisor networking; router uses kernel TUN |
| `ssh` | Access via MikroTik SSH + `tailscale` CLI instead |
| `linuxdnsfight` | inotify on `/etc/resolv.conf`; no systemd in container |
| `networkmanager` / `resolved` / `dbus` / `sdnotify` | No systemd stack in container |
| `drive` / `taildrop` / `webclient` | Not useful on a headless router |
| All GUI / desktop / cloud / k8s features | Irrelevant |
### Why the built-in updater is removed
Tailscale's `clientupdate` feature (and `tailscale update` / auto-update) is
**intentionally compiled out**, for several compounding reasons:
- **It would defeat the entire purpose of this build.** `clientupdate`
downloads the *full official upstream binary* — built with every feature, tens
of megabytes — and writes it onto the device. This image exists precisely to
be a few MB with only router-relevant features; letting it pull the upstream
binary would undo all of that.
- **It would risk filling the flash.** On a 16 MB-class device, downloading and
unpacking a large upstream binary can simply run the device out of space, and
the download itself causes significant flash writes.
- **It can't work on a container image anyway.** The binary lives in a
read-only, content-addressed image layer. An in-place self-update has nowhere
valid to write and would not survive a container recreate — the next pull
would replace it regardless.
- **Updates should be controlled and reproducible.** Instead of the client
silently swapping its own binary, new versions are produced by rebuilding and
republishing *this* image through CI (pinned dependencies, known feature set,
multi-arch). The device then pulls a new image **only when it actually
changed** — see [Versioning & releases](#versioning--releases).
Net effect: the update path is explicit, version-pinned, flash-safe, and keeps
the on-device footprint minimal — none of which the built-in updater could
provide here.
### Why netmap disk-caching is removed
The `cachenetmap` feature is **intentionally omitted**. It is worth being
precise about what it does and doesn't do:
- The network map always lives in the daemon's **memory** — this is core
behavior, not gated by any feature flag. A daemon that has connected once and
then **loses its control-plane connection keeps that map** and can still
reach known peers. The data path is direct WireGuard / DERP between nodes; the
control plane is only for coordination, not for relaying your traffic. So
initiating a connection to a reachable peer during a control outage works
**without** this feature, as long as the daemon stays running.
- `cachenetmap` *only* adds writing that map to **disk**, so the node can come
online from the last-known config after a **cold start that coincides with a
control-plane outage** — a narrow case (it requires a reboot *and* control
being unreachable at that moment *and* needing connectivity before control
recovers).
The cost of the feature is that it writes the netmap to flash, and the netmap
changes frequently on an active tailnet (every peer endpoint/DERP/online-status
change). For a flash-constrained router that is the wrong trade: frequent writes
to internal flash to buy resilience for a rare corner case. Omitting it keeps
the in-memory resilience (the common case) while eliminating per-netmap flash
writes. Only `tailscaled.state` (written on auth / key rotation) ever touches
flash.
## Volume layout
Two mount points, with different persistence requirements:
```
/var/lib/tailscale persistent — node identity, auth state
bind-mount to MikroTik disk storage
written rarely (only on auth / key rotation /
prefs change); netmap is not cached to disk
(cachenetmap omitted), so no per-netmap writes
/var/run/tailscale ephemeral — daemon Unix socket
mount as tmpfs
lost on reboot, recreated on start
```
Only the small, rarely-written state file touches flash; the socket dir is
tmpfs. The netmap is held in memory only — see
[Why netmap disk-caching is removed](#why-netmap-disk-caching-is-removed).
## Flash wear protection
Several measures are in place to avoid wearing out internal flash:
- `clientupdate` omitted from binary — no background update downloads
([why](#why-the-built-in-updater-is-removed))
- `cachenetmap` omitted from binary — netmap is never written to disk, so the
frequent netmap updates cause no flash writes
([why](#why-netmap-disk-caching-is-removed))
- `logtail` omitted from binary — no log upload attempts
- `--no-logs-no-support` passed to daemon — suppresses any remaining log
buffering
- `/var/run/tailscale` socket on tmpfs — runtime files never reach flash
- Only `/var/lib/tailscale/tailscaled.state` touches persistent storage,
and it is written only when the node authenticates or rotates its key
## Versioning & releases
Released images are versioned as:
```
v<TAILSCALE_VERSION>-mt.<N>
```
e.g. `v1.98.3-mt.1`. The two parts mean:
- **`v<TAILSCALE_VERSION>`** — the bundled Tailscale version (the "what's
inside" identifier), taken from `ARG TAILSCALE_VERSION` in the Dockerfile.
- **`mt.<N>`** — the local revision. It only changes on a *meaningful* release,
never on a build-system-only rebuild.
### When a release happens
| Trigger | Result |
|---|---|
| Renovate bumps `TAILSCALE_VERSION` (merged to `main`) | CI **auto-creates** git tag `v<new>-mt.1` → image published |
| You make a meaningful fix/change on the current Tailscale version | **You** create the next tag manually (`v<ts>-mt.2`, `mt.3`, …) → image published |
| Dependency-only bump (Go / Alpine / busybox / Dockerfile syntax) | **No release.** Rides along with the next Tailscale bump or manual tag |
So routers only ever see a new release for Tailscale bumps or your deliberate
fixes — build-system churn doesn't trigger updates.
Each published image is stamped with `org.opencontainers.image.version` equal to
its full tag; this is the value the MikroTik update job compares against the
registry to decide whether to recreate the container.
### How it's wired (Woodpecker)
- **`.woodpecker/release-tag.yaml`** — on push to `main`, parses
`TAILSCALE_VERSION`; if no `v<ts>-mt.*` tag exists yet, creates and pushes
`v<ts>-mt.1` (using the Gitea token from OpenBao). It never creates `mt.2+`.
- **`.woodpecker/release.yaml`** — on a `v*-mt.*` tag push, builds the
multi-arch manifest (amd64 + arm64 + arm/v7) and pushes it to
`gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale` as both `:<tag>` and
`:stable`. Registry creds come from OpenBao (`secret/container-registry`).
To cut a release manually, see
[DEVELOPMENT.md → Cutting a manual release](DEVELOPMENT.md#cutting-a-manual-release).
### How the router consumes releases
The RouterOS update script (`routeros/update-tailscale.rsc`) compares the
`:stable` **manifest digest** against the digest from the last deploy:
- It fetches the digest using an anonymous bearer token (the Gitea package is
public) — no credentials stored on the router.
- **Unchanged → does nothing** (no pull, no recreate, no flash wear).
- **Changed → recreates the container** from the new image, then records the
new digest.
Because `:stable` only moves on a meaningful release, dependency-only rebuilds
never trigger an update on the router. Setup is in
[USAGE.md → step 7](USAGE.md#7-enable-automatic-updates).
## Dependency pinning & automated updates
All upstream dependencies are version-pinned for reproducible builds, fully
qualified (no floating `major.minor` tags):
| Dependency | Where | Pinned form |
|---|---|---|
| Go toolchain | `Dockerfile` `FROM golang:…` | full version tag + `@sha256` digest |
| Alpine (busybox build base) | `Dockerfile` `FROM alpine:…` | full version tag + `@sha256` digest |
| Tailscale | `Dockerfile` `ARG TAILSCALE_VERSION` | full git release tag |
| busybox | `Dockerfile` `ARG BUSYBOX_VERSION` | full release version |
| Renovate / OpenBao | `.woodpecker/*.yaml` `image:` | full version tag |
Updates are proposed automatically by [Renovate](https://docs.renovatebot.com/),
run **self-hosted** from a Woodpecker cron pipeline (Woodpecker has no native
Renovate support):
- `renovate.json` — repository rules. All dependencies follow the latest
upstream releases; each bump arrives as its own PR. Base image tags also get
their `@sha256` digests refreshed via `pinDigests`. Notable rules:
- `tailscale` only follows **stable** releases — Tailscale uses even minor
versions for stable (`v1.98.x`) and odd for unstable (`v1.99.x`), so the
rule filters to even minors.
- `.woodpecker/renovate.yaml` — the scheduled job that runs `renovate/renovate`
against this repo.
- `.woodpecker/pr-build.yaml` — builds all three arches (no push) on every PR
and reports status to Gitea. This is the gate for automerge.
### Automerge policy
These updates **automerge** once the PR build passes — they reach `:stable`
(and the routers) without manual review:
| Update | Automerge? | Why |
|---|---|---|
| Tailscale stable (patch **and** minor) | ✅ | the point of the project; the PR build catches breakage |
| Go / Alpine / busybox **patch** | ✅ | bugfix-only, build-internal |
| Base-image **digest** refresh (same tag) | ✅ | content refresh, no version change |
| Go / Alpine / busybox **minor/major** | ❌ manual | larger toolchain/base changes warrant review |
| Renovate runner, syntax frontend | ❌ manual | tooling — review deliberately |
**Important:** automerge depends on the PR build being a **required status
check** in Gitea branch protection. The PR build only proves the image *builds*
for all arches — it does not run the daemon, so a runtime regression in a new
Tailscale release could still be automerged. That is an accepted trade-off for
the convenience of unattended Tailscale updates; if a release misbehaves, roll
back by re-tagging the previous `v…-mt.N` (the immutable tags are kept).
Validate the configs locally:
```sh
# Renovate repo config
docker run --rm -e RENOVATE_CONFIG_TYPE=repo -v "$PWD":/work -w /work \
--entrypoint renovate-config-validator renovate/renovate
# Woodpecker pipeline
docker run --rm -v "$PWD":/work -w /work \
woodpeckerci/woodpecker-cli:v3 lint .woodpecker/renovate.yaml
```
## References
- [Tailscale: Smaller binaries for embedded devices](https://tailscale.com/docs/how-to/set-up-small-tailscale)
- [Renovate self-hosting](https://docs.renovatebot.com/getting-started/running/)
- [Woodpecker cron jobs](https://woodpecker-ci.org/docs/usage/cron)
- [MikroTik Container documentation](https://help.mikrotik.com/docs/display/ROS/Container)
- [Tailscale subnet routers](https://tailscale.com/kb/1019/subnets)
- [Tailscale exit nodes](https://tailscale.com/kb/1103/exit-nodes)
+159
View File
@@ -0,0 +1,159 @@
# Development
Building the image, testing it locally, bumping the Tailscale version, and
cutting releases. This is for working *on* this repo; if you just want to run
the published image on a router, see [USAGE.md](USAGE.md).
For the reasoning behind the build choices, see [DESIGN.md](DESIGN.md).
## Prerequisites
- `docker` with `buildx`.
- For cross-arch builds, QEMU/binfmt emulators registered:
```sh
docker run --privileged --rm tonistiigi/binfmt --install arm64,arm
```
The Go toolchain and busybox are built inside the image stages, so no local Go
install is needed.
## Building
### All architectures at once
Use the helper script:
```sh
# Build all arches and load into local docker
./build.sh
# Build all arches and also export per-arch tarballs into ./dist/
./build.sh --tar
# Build a single arch
./build.sh arm64
./build.sh --tar armv7
```
### Manual single-arch build
The architecture is selected via `buildx --platform`; the Dockerfile maps it to
the correct `GOARCH`/`GOARM` automatically:
```sh
docker buildx build --platform linux/arm64 --load -t mikrotik-tailscale:arm64 .
docker buildx build --platform linux/arm/v7 --load -t mikrotik-tailscale:armv7 .
docker buildx build --platform linux/amd64 --load -t mikrotik-tailscale:amd64 .
```
To build for a different Tailscale version, add:
```sh
--build-arg TAILSCALE_VERSION=v1.98.3
```
### Notes
- The Go builder cross-compiles natively (fast); only the busybox stage runs
under emulation for non-native targets.
- The build prints the resolved target and Go build tags, e.g.:
```
Cross-compiling: GOOS=linux GOARCH=arm64 GOARM=
Build tags: ts_include_cli,ts_omit_ace,ts_omit_acme,...
```
## Running (local test)
Quick smoke test on a dev machine with Docker (this is *not* how it runs on a
router — see [USAGE.md](USAGE.md) for that):
```sh
# Create a volume for persistent state
docker volume create tailscale-state
# Start the daemon
docker run -d \
--name tailscale \
--cap-add NET_ADMIN \
--cap-add NET_RAW \
--device /dev/net/tun \
--tmpfs /var/run/tailscale \
-v tailscale-state:/var/lib/tailscale \
mikrotik-tailscale
# Authenticate (opens browser / prints auth URL)
docker exec tailscale tailscale login
# Check status
docker exec tailscale tailscale status
# Advertise a subnet
docker exec tailscale tailscale set --advertise-routes=192.168.88.0/24
# Advertise as exit node
docker exec tailscale tailscale set --advertise-exit-node
```
Subnet routes and exit node advertisement must also be approved in the
[Tailscale admin console](https://login.tailscale.com/admin/machines).
For headless / unattended auth, use a reusable auth key from the admin console
(**Settings → Keys**):
```sh
docker exec tailscale tailscale up \
--authkey=tskey-auth-<key> \
--advertise-routes=192.168.88.0/24 \
--advertise-exit-node
```
## Bumping the Tailscale version
Version bumps (Tailscale, busybox, base image digests) are normally proposed
automatically via Renovate (see
[DESIGN.md → Dependency pinning](DESIGN.md#dependency-pinning--automated-updates)).
Merge the Renovate PR; a Tailscale bump then auto-publishes a new release.
The feature allowlist in the Dockerfile carries forward automatically across
Tailscale versions — any new `ts_omit_*` tags introduced in a new release will
be omitted by default.
To bump manually, edit `ARG TAILSCALE_VERSION` in the `Dockerfile` (so the pin
stays in version control) and rebuild:
```sh
./build.sh --tar # rebuild all arches at the pinned version
# or, override at build time without editing the Dockerfile:
docker buildx build --platform linux/arm64 \
--build-arg TAILSCALE_VERSION=v1.100.0 \
--load -t mikrotik-tailscale:arm64 .
```
## Cutting a manual release
A Tailscale bump auto-creates `v<ts>-mt.1` and publishes it. For a meaningful
fix/change on the *current* Tailscale version, tag the next `mt.N` by hand:
```sh
# fix something, commit to main, then:
git tag -a v1.98.3-mt.2 -m "Fix X"
git push origin v1.98.3-mt.2
```
The tag push triggers the build + multi-arch publish automatically. See
[DESIGN.md → Versioning & releases](DESIGN.md#versioning--releases) for the full
scheme and CI wiring.
## Validating CI configs locally
```sh
# Renovate repo config
docker run --rm -e RENOVATE_CONFIG_TYPE=repo -v "$PWD":/work -w /work \
--entrypoint renovate-config-validator renovate/renovate
# Woodpecker pipelines
docker run --rm -v "$PWD":/work -w /work \
woodpeckerci/woodpecker-cli:v3 lint .woodpecker/renovate.yaml
```
+195
View File
@@ -0,0 +1,195 @@
# Usage
Deploying the published image on a MikroTik router and operating it: networking,
authentication, MagicDNS, and automatic updates. This uses the prebuilt image
from the registry — you don't need to build anything.
To build the image yourself, see [DEVELOPMENT.md](DEVELOPMENT.md). For the
reasoning behind these choices, see [DESIGN.md](DESIGN.md).
## Deploy on MikroTik (RouterOS)
Verified on RouterOS 7.21.2 (arm64, CRS418). Commands are grouped into
copy-paste blocks, defaults should fit most configurations.
> Because the image has no built-in updater (the `clientupdate` feature is
> [intentionally compiled out](DESIGN.md#why-the-built-in-updater-is-removed)),
> updates are handled by a small script that recreates container when
> the update is published — see [step 7](#7-enable-automatic-updates).
### 0. Prerequisites
- RouterOS >7.13 with the **container** package installed.
- Container mode enabled ([documentation](https://manual.mikrotik.com/docs/System%20Information%20and%20Utilities/device-mode/#changing-mode-of-device-mode)):
```
/system/device-mode/update container=yes
```
### 1. Networking (veth + routing)
Gives the container an internal IP and configures routing to the tailnet.
Pick a subnet that doesn't clash with your LAN.
```
/interface/veth/add name=veth-tailscale address=172.20.0.2/24 gateway=172.20.0.1
/interface/bridge/add name=containers
/ip/address/add address=172.20.0.1/24 interface=containers
/interface/bridge/port/add bridge=containers interface=veth-tailscale
/ip/route/add dst-address=100.64.0.0/10 gateway=172.20.0.2 comment=Tailnet
```
If you want the router to have access to subnets shared by other tailscale nodes,
add route for each one.
```
/ip/route/add dst-address=[subnet CIDR] gateway=172.20.0.2 comment="Another network via tailscale"
```
If you want to share your LAN via tailscale, add it as an advertised route in
[step 5](#5-authenticate). You may also need additional firewall configuration
to accept connections to or from tailnet if you have one configured.
You should not need any additional NAT rules.
### 2. Extraction scratch dir (tmpfs)
Put the image extraction scratch dir on **tmpfs** (RAM) so the pull/extract
happen in RAM and doesn't fill up or wear out flash:
```
/disk/add type=tmpfs tmpfs-max-size=256M slot=tmp
/container/config/set tmpdir=tmp
```
### 3. Persistent state mount (the only thing on flash)
Only the tiny `tailscaled.state` (node identity / key) needs to persist. Mount
just that directory:
```
/container/mounts/add list=tailscale_state src=tailscale/state dst=/var/lib/tailscale
```
### 4. Add and start the container
```
/container/add \
remote-image=gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale:stable \
interface=veth-tailscale \
root-dir=tailscale/root \
mountlists=tailscale_state \
logging=yes \
start-on-boot=yes \
name=tailscale
```
Wait for the pull/extract to finish (`status=stopped`), then start it:
```
/container/print ;# wait until status=stopped
/container/start [find where name=tailscale]
/log/print where message~"tailscale"
```
The daemon is now running but **not yet authenticated**.
### 5. Authenticate
> This image runs `tailscaled` via a tiny entrypoint (which enables IP
forwarding, then `exec`s the daemon) and does **not** bundle Tailscale's
`containerboot` wrapper, so the `TS_AUTHKEY` environment variable is **not**
read automatically. You authenticate with `tailscale up --authkey=...` after the
container starts.
Enter the container shell and bring Tailscale up with your auth key.
Use `tailscale up --help` to see list of commands, customize it to your needs,
add subnets (eg. your LAN) or exit-node advertisements in command below.
```
/container/shell [find where name=tailscale]
# inside the container — CHANGE ME: your key (and adjust routes/subnet):
tailscale up --authkey=tskey-auth-CHANGEME \
--accept-routes \
--snat-subnet-routes=false \
--advertise-routes=172.20.0.0/24 \
--advertise-exit-node
exit
```
The node now appears in your Tailscale admin console. Approve the advertised
routes / exit node there. Because the auth state is written to the persisted
`tailscaled.state`, you only do this once — it survives reboots and updates.
### 6. Enable automatic updates
First, edit the `CONFIG` block at the top of `routeros/update-tailscale.rsc` if
you changed any names in the steps above. The defaults match this guide
(`name=tailscale`, `root-dir=tailscale/root`, `mountlists=tailscale_state`,
`interface=veth-tailscale`).
Copy the file to the router (Winbox **Files** drag-and-drop, or SFTP), then
create a **named script** from it and schedule it:
```
# Create the named script from the uploaded file's contents.
/system/script/add name=update-tailscale source=[/file/get update-tailscale.rsc contents]
# Run it daily.
/system/scheduler/add name=update-tailscale interval=1d \
on-event="/system/script/run update-tailscale" \
comment="Check for mikrotik-tailscale image updates"
```
If you later upload a changed version of the file, refresh the script:
```
/system/script/set update-tailscale source=[/file/get update-tailscale.rsc contents]
```
What it does on each run:
1. Reads the current `:stable` manifest digest from the registry (anonymous —
the package is public).
2. Compares it to the digest stored from the last deploy.
3. **Unchanged → does nothing** (no pull, no flash writes).
4. **Changed → recreates the container** from the new image and records the new
digest.
Since `:stable` only moves on a meaningful release, the router never re-pulls
for build-system-only changes — see
[DESIGN.md → Versioning & releases](DESIGN.md#versioning--releases).
> The digest fetch/compare logic is verified against the registry; the RouterOS
> container/file API calls (marked in the script) should be smoke-tested once on
> your device, since those idioms vary slightly by RouterOS version.
## MagicDNS
To use MagicDNS name resolution, configure MikroTik's DNS to forward `.ts.net`
queries to Tailscale's magic DNS resolver:
```
/ip dns static
add name="ts.net" type=FWD forward-to=100.100.100.100 match-subdomain=yes
```
When this is configured, you can connect to other tailscale machines using
`[device name].[tailnet name].ts.net`. You can see and change assigned
Tailnet DNS name in Tailscale admin panel under DNS tab.
## Updating
You don't normally do anything: when a new release is published, the
auto-update script ([step 6](#6-enable-automatic-updates)) detects the changed
`:stable` image on its next scheduled run and recreates the container. Your
node identity and settings persist across the update via the state mount.
To force an immediate check instead of waiting for the schedule:
```
/system/script/run update-tailscale
```
To pin a specific version instead of tracking `:stable`, set `remote-image` (and
the script's `imageRef`) to an immutable tag like
`...mikrotik-tailscale:v1.98.3-mt.1`.
+28 -4
View File
@@ -7,10 +7,12 @@
],
"labels": ["dependencies"],
"rebaseWhen": "behind-base-branch",
"dockerfile": {
"pinDigests": true
},
"packageRules": [
{
"matchManagers": ["dockerfile"],
"description": "Keep base-image tags pinned to a digest.",
"pinDigests": true
},
{
"matchDatasources": ["github-releases"],
"matchPackageNames": ["tailscale/tailscale"],
@@ -18,6 +20,28 @@
"extractVersion": "^v(?<version>\\d+\\.\\d+\\.\\d+)$",
"allowedVersions": "/^\\d+\\.\\d*[02468]\\.\\d+$/",
"ignoreUnstable": true
},
{
"matchDatasources": ["github-releases"],
"matchPackageNames": ["tailscale/tailscale"],
"description": "Automerge all stable Tailscale releases (patch AND minor) once the PR build passes.",
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
},
{
"matchManagers": ["dockerfile"],
"matchPackageNames": ["golang", "alpine", "busybox"],
"description": "Automerge PATCH-only bumps of build components (Go/Alpine/busybox) once the PR build passes; review minor/major manually.",
"matchUpdateTypes": ["patch"],
"automerge": true
},
{
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["digest", "pinDigest"],
"description": "Automerge base-image digest refreshes (same tag, new sha256) once the PR build passes.",
"automerge": true
}
]
],
"automergeType": "pr",
"platformAutomerge": true
}
+221
View File
@@ -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
# =============================================================================