commit d912a450bfc14e093498aa906538703a3536cb37 Author: Lumpiasty Date: Thu May 28 23:03:21 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..988ce9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Build artifacts produced by build.sh --tar +/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27d6ae9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,237 @@ +# syntax=docker/dockerfile:1 +# ============================================================================= +# Multi-architecture build +# ============================================================================= +# Supported MikroTik Container architectures (build with `docker buildx`): +# linux/amd64 x86 / CHR +# linux/arm64 RB5009, CCR2xxx, hAP ax3, L009, Chateau (most modern) +# linux/arm/v7 ARMv7: hAP ac2, RB3011, RB4011, RB1100AHx4 +# +# NOT supported here: ARMv5 (hEX Refresh / hAP ax S, EN7562CT CPU). ARMv5 has +# no Alpine/musl base image, so it cannot use the musl + scratch design below; +# it would need a glibc (Debian) base and produces a much larger image. See +# README for details if you need it. +# +# The Go builder cross-compiles, so it always runs NATIVELY on the build host +# ($BUILDPLATFORM) for speed; only the busybox stage and the final image run on +# the target platform. + +# ============================================================================= +# Stage 1: Build Tailscale combined binary (cross-compiled, runs natively) +# ============================================================================= +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder + +ARG TAILSCALE_VERSION=v1.98.3 + +# Provided automatically by buildx for the target platform. +ARG TARGETOS +ARG TARGETARCH +ARG TARGETVARIANT + +RUN apk add --no-cache \ + git \ + upx \ + ca-certificates + +# Clone the exact release tag (no full history) +RUN git clone --depth 1 --branch ${TAILSCALE_VERSION} \ + https://github.com/tailscale/tailscale.git /src/tailscale + +WORKDIR /src/tailscale + +# Build a minimal combined binary (tailscale CLI + tailscaled daemon in one file). +# +# Tag strategy — ALLOWLIST, not blocklist: +# 1. cmd/featuretags --min --add=osrouter generates the full ts_omit_* set +# (identical to build_dist.sh --extra-small), omitting every optional feature. +# 2. We pipe that through sed to REMOVE the ts_omit_ tags for the features +# we explicitly want, leaving everything else omitted. +# 3. We prepend ts_include_cli (combined daemon+CLI binary). +# +# This means any NEW ts_omit_* tag added in a future Tailscale release will +# automatically be omitted — we only get features we consciously opt into. +# +# Features opted in (removed from the omit list): +# advertiseexitnode — run as exit node for the tailnet +# advertiseroutes — advertise LAN subnets to the tailnet +# useexitnode — route router's own traffic via a remote exit node +# useroutes — accept routes advertised by other tailnet nodes +# dns — MagicDNS; configure MikroTik DNS to forward +# *.ts.net → 100.100.100.100; use --no-dns daemon +# flag to skip writing /etc/resolv.conf +# 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 +# +# Everything else remains omitted, including (rationale): +# clientupdate — updates managed via Docker image rebuild +# logtail — no persistent log writes to flash; also pass +# --no-logs-no-support at runtime +# netstack+gro — userspace networking; router uses kernel TUN +# ssh — not needed; access via MikroTik SSH + tailscale CLI +# all GUI/desktop/cloud/k8s features — irrelevant for a headless router + +RUN mkdir -p /out && \ + ALL_OMIT=$(GOOS= GOARCH= go run ./cmd/featuretags --min --add=osrouter) && \ + TAGS=$(echo "ts_include_cli,${ALL_OMIT}" | \ + sed \ + -e 's/ts_omit_advertiseexitnode,\{0,1\}//g' \ + -e 's/ts_omit_advertiseroutes,\{0,1\}//g' \ + -e 's/ts_omit_useexitnode,\{0,1\}//g' \ + -e 's/ts_omit_useroutes,\{0,1\}//g' \ + -e 's/ts_omit_dns,\{0,1\}//g' \ + -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/,$//' \ + ) && \ + echo "Build tags: ${TAGS}" && \ + # Map Docker's TARGETARCH/TARGETVARIANT to Go's GOARCH/GOARM. + # For arm/v7 -> GOARM=7 (hardfloat). Other arches leave GOARM unset. + GOARM="" && \ + if [ "${TARGETARCH}" = "arm" ]; then \ + case "${TARGETVARIANT}" in \ + v7) GOARM=7 ;; \ + v6) GOARM=6 ;; \ + v5) GOARM=5 ;; \ + *) GOARM=7 ;; \ + esac; \ + fi && \ + echo "Cross-compiling: GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} GOARM=${GOARM}" && \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} GOARM=${GOARM} \ + go build \ + -tags "${TAGS}" \ + -gcflags="all=-l" \ + -ldflags="-s -w" \ + -trimpath \ + -o /out/tailscale.combined \ + ./cmd/tailscaled + +# Compress with UPX LZMA. +# Expected: ~14 MB raw → ~3.8 MB compressed (with -gcflags=all=-l) +RUN upx --lzma --best /out/tailscale.combined + +# ============================================================================= +# Stage 2: Custom minimal busybox +# ============================================================================= +# The official busybox:musl image ships all ~404 applets at ~1.24 MB. For a +# debug shell on a flash-constrained router we only need ~100 applets, so we +# build a static busybox from source with a curated applet set, then UPX it +# down to ~230 kB on disk. +# +# UPX is normally dangerous with busybox: the ash shell's standalone applet +# dispatch re-execs /proc/self/exe, which UPX breaks, so typed commands fail +# (https://github.com/upx/upx/issues/248, closed as "invalid"). We sidestep +# this by building WITHOUT the standalone/nofork features (see +# busybox-applets.config) and providing an explicit /bin/ symlink +# farm. Commands then resolve via the ordinary PATH -> symlink -> argv[0] +# dispatch, which works fine under UPX. The cost is a fork+exec per command, +# acceptable for an occasional debug shell. RouterOS stores the EXTRACTED +# rootfs on disk (overlayfs), so the ~190 kB UPX saving is real on-disk space. +# +# 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 AS busybox + +ARG BUSYBOX_VERSION=1.37.0 + +RUN apk add --no-cache build-base linux-headers wget bzip2 perl upx + +RUN wget -q https://busybox.net/downloads/busybox-${BUSYBOX_VERSION}.tar.bz2 \ + && tar xf busybox-${BUSYBOX_VERSION}.tar.bz2 +WORKDIR /busybox-${BUSYBOX_VERSION} + +# allnoconfig = every feature OFF; then enable only the curated applet set. +COPY busybox-applets.config /tmp/applets.config +RUN make allnoconfig && \ + while read -r sym; do \ + case "$sym" in ''|\#*) continue ;; esac; \ + if grep -q "^# CONFIG_${sym} is not set" .config; then \ + sed -i "s/^# CONFIG_${sym} is not set/CONFIG_${sym}=y/" .config; \ + elif ! grep -q "^CONFIG_${sym}=y" .config; then \ + echo "CONFIG_${sym}=y" >> .config; \ + fi; \ + done < /tmp/applets.config && \ + yes "" | make oldconfig >/dev/null 2>&1 && \ + make -j"$(nproc)" >/dev/null 2>&1 && \ + strip busybox + +# Lay out a minimal rootfs with busybox + an applet symlink per applet. +# Symlinks (argv[0] dispatch) are how busybox selects an applet and make the +# applets resolvable via $PATH from inside the shell. We derive the applet +# names from the build .config: a symbol is an applet if its lowercase name +# resolves to a runnable applet (busybox returns "applet not found" on stderr +# for non-applet symbols like FEATURE_* / STATIC, which we filter out). +# We generate symlinks from the UNCOMPRESSED binary (so the probe is reliable), +# then UPX-compress the binary in place afterwards. +RUN mkdir -p /rootfs/bin && \ + grep '^CONFIG_.*=y' .config \ + | sed -e 's/^CONFIG_//' -e 's/=y$//' \ + | tr 'A-Z' 'a-z' \ + | while read -r app; do \ + if ! ./busybox "$app" --help 2>&1 | grep -q "applet not found"; then \ + ln -sf /bin/busybox /rootfs/bin/"$app"; \ + fi; \ + done && \ + ln -sf /bin/busybox /rootfs/bin/sh && \ + echo "Applet symlinks created: $(ls /rootfs/bin | wc -l)" && \ + upx --lzma --best busybox && \ + cp busybox /rootfs/bin/busybox + +# ============================================================================= +# Stage 3: Final runtime image +# ============================================================================= +FROM scratch + +# Custom static busybox + applet symlinks (provides /bin/sh and utilities) +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"] + +# Ensure /usr/local/bin and busybox dirs are on PATH for interactive shells +ENV PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# ----------------------------------------------------------------------------- +# Volume layout (to be created by deploy script): +# +# /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 +# +# /var/run/tailscale — runtime socket dir +# → tmpfs, lost on reboot (expected) +# ----------------------------------------------------------------------------- +VOLUME ["/var/lib/tailscale"] + +ENTRYPOINT ["/usr/local/bin/tailscaled"] + +# Default flags: +# --no-logs-no-support disables logtail uploads (logtail binary code is +# omitted, but the flag also suppresses any remaining +# log buffering and prevents the daemon from trying +# to write log files) +# --state persistent node identity / authkey storage +# --socket CLI communication socket (on tmpfs) +# --statedir where cache and other runtime files land +CMD ["--no-logs-no-support", \ + "--state=/var/lib/tailscale/tailscaled.state", \ + "--socket=/var/run/tailscale/tailscaled.sock", \ + "--statedir=/var/lib/tailscale"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c08465 --- /dev/null +++ b/README.md @@ -0,0 +1,285 @@ +# mikrotik-tailscale + +A minimal Tailscale Docker image built for MikroTik routers running +[Container](https://help.mikrotik.com/docs/display/ROS/Container). Fits in +16 MB internal flash. Built from source with only router-relevant features +included. + +## Supported architectures + +| Docker platform | RouterOS arch | Example devices | +|---|---|---| +| `linux/amd64` | x86 / CHR | x86 installs, Cloud Hosted Router | +| `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 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. + +## Image size + +On-disk footprint once extracted (this is what matters — RouterOS stores the +**extracted** rootfs on disk via overlayfs, not the compressed layers): + +| Component | On-disk size | +|---|---| +| 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/` 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- \ + --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 + +Update the `TAILSCALE_VERSION` build arg and rebuild the image. The feature +allowlist in the Dockerfile carries forward automatically — any new +`ts_omit_*` tags introduced in the new version will be omitted by default. + +```sh +TAG=v1.99.0 ./build.sh --tar # rebuild all arches with the new version +# or, single arch: +docker buildx build --platform linux/arm64 \ + --build-arg TAILSCALE_VERSION=v1.99.0 \ + --load -t mikrotik-tailscale:v1.99.0-arm64 . +``` + +## References + +- [Tailscale: Smaller binaries for embedded devices](https://tailscale.com/docs/how-to/set-up-small-tailscale) +- [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) diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..aa8363a --- /dev/null +++ b/build.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Build mikrotik-tailscale images for all supported MikroTik architectures. +# +# Produces one OCI image per architecture and, optionally, a per-arch tarball +# suitable for `/container/add file=...` on RouterOS. +# +# Usage: +# ./build.sh # build all arches, load into local docker +# ./build.sh arm64 # build a single arch +# ./build.sh --tar # build all arches and export .tar files +# ./build.sh --tar arm64 # build one arch and export its .tar +# +# Requirements: +# - docker with buildx +# - For non-native targets: binfmt/QEMU emulators registered, e.g.: +# docker run --privileged --rm tonistiigi/binfmt --install arm64,arm +set -eu + +IMAGE="${IMAGE:-mikrotik-tailscale}" +TAG="${TAG:-latest}" +OUTDIR="${OUTDIR:-dist}" + +# MikroTik Container supported architectures (Docker platform -> tag suffix). +# ARMv5 (hEX Refresh / hAP ax S) is intentionally excluded; it has no musl +# base and needs a separate glibc build — see README. +PLATFORMS="linux/amd64:amd64 linux/arm64:arm64 linux/arm/v7:armv7" + +make_tar=0 +only_arch="" +for arg in "$@"; do + case "$arg" in + --tar) make_tar=1 ;; + -*) echo "unknown flag: $arg" >&2; exit 1 ;; + *) only_arch="$arg" ;; + esac +done + +build_one() { + platform="$1" + suffix="$2" + ref="${IMAGE}:${TAG}-${suffix}" + + echo ">>> Building ${ref} for ${platform}" + set -- --platform "${platform}" --load -t "${ref}" + if [ -n "${TAILSCALE_VERSION:-}" ]; then + set -- "$@" --build-arg "TAILSCALE_VERSION=${TAILSCALE_VERSION}" + fi + docker buildx build "$@" . + + if [ "${make_tar}" -eq 1 ]; then + mkdir -p "${OUTDIR}" + out="${OUTDIR}/${IMAGE}-${TAG}-${suffix}.tar" + echo ">>> Exporting ${out}" + docker save "${ref}" -o "${out}" + echo " $(ls -l "${out}" | awk '{printf "%.1f MB", $5/1048576}') ${out}" + fi +} + +for entry in $PLATFORMS; do + platform="${entry%%:*}" + suffix="${entry##*:}" + if [ -n "${only_arch}" ] && [ "${only_arch}" != "${suffix}" ]; then + continue + fi + build_one "${platform}" "${suffix}" +done + +echo ">>> Done." +echo "Images:" +docker images "${IMAGE}" --format ' {{.Repository}}:{{.Tag}}\t{{.Size}}' diff --git a/busybox-applets.config b/busybox-applets.config new file mode 100644 index 0000000..511661f --- /dev/null +++ b/busybox-applets.config @@ -0,0 +1,202 @@ +# Curated busybox applet set for a Tailscale-on-MikroTik debug shell. +# +# This file is consumed by the Dockerfile's busybox build stage. It starts +# from `make allnoconfig` (everything OFF) and turns ON only the symbols +# listed here, keeping the binary small (~420 kB static vs ~1.24 MB for the +# full official busybox). +# +# Format: one CONFIG symbol per line (without the CONFIG_ prefix or =y). +# Lines starting with # and blank lines are ignored. +# +# IMPORTANT: this busybox is intentionally NOT UPX-compressed. UPX breaks +# busybox's internal applet dispatch from the ash shell — typed commands +# fall through to a $PATH lookup instead of running the built-in applet. +# See https://github.com/upx/upx/issues/248 (closed as "invalid"; it is a +# busybox/UPX interaction the UPX project will not fix). A custom static +# build is both smaller than a UPX'd full busybox AND avoids this entirely. + +# --- Static build (runs anywhere, no dynamic loader) --- +STATIC + +# --- Large File Support (64-bit off_t) --- +# musl (Alpine) always uses a 64-bit off_t. Without LFS, busybox's off_t size +# self-check (BUG_off_t_size_is_misdetected) fails to compile on 32-bit targets +# such as arm/v7 — especially under QEMU emulation. Enabling LFS is correct for +# musl on every architecture, so we set it unconditionally. +LFS + +# --- Shell: ash, standalone mode so typed commands resolve to applets --- +ASH +SH_IS_ASH +ASH_INTERNAL_GLOB +ASH_BASH_COMPAT +ASH_JOB_CONTROL +ASH_ALIAS +ASH_GETOPTS +ASH_CMDCMD +ASH_ECHO +ASH_PRINTF +ASH_TEST +ASH_HELP +ASH_OPTIMIZE_FOR_SIZE +# NOTE: FEATURE_SH_STANDALONE, FEATURE_PREFER_APPLETS and FEATURE_SH_NOFORK +# are intentionally LEFT OFF (they are off by default in allnoconfig). +# +# Those features make the shell run applets internally by re-exec'ing +# /proc/self/exe instead of doing a normal PATH lookup. That /proc/self/exe +# path is exactly what UPX breaks (https://github.com/upx/upx/issues/248): +# under UPX the shell fails to find its own applets and falls through to a +# (nonexistent) PATH binary. +# +# By leaving them off, typed commands resolve via the ordinary PATH -> +# /bin/ symlink -> busybox argv[0] dispatch, which works correctly +# even when the busybox binary IS UPX-compressed. This lets us UPX busybox +# (~424 kB -> ~230 kB on-disk) without breaking the shell. The cost is a +# fork+exec per command instead of a nofork internal call, which is fine +# for an occasional debug shell. +FEATURE_EDITING +FEATURE_EDITING_HISTORY +FEATURE_TAB_COMPLETION +FEATURE_SUID +LONG_OPTS + +# --- Coreutils --- +LS +FEATURE_LS_FILETYPES +FEATURE_LS_SORTFILES +FEATURE_LS_TIMESTAMPS +FEATURE_LS_USERNAME +FEATURE_LS_COLOR +CAT +ECHO +PRINTF +PWD +TRUE +FALSE +TEST +MKDIR +RMDIR +RM +MV +CP +LN +TOUCH +STAT +READLINK +REALPATH +BASENAME +DIRNAME +CHMOD +CHOWN +CHGRP +HEAD +FEATURE_FANCY_HEAD +TAIL +FEATURE_FANCY_TAIL +WC +SORT +FEATURE_SORT_BIG +UNIQ +CUT +TR +EXPR +SEQ +SLEEP +YES +ENV +PRINTENV +WHICH +WHOAMI +ID +DATE +HOSTNAME +UNAME +MKTEMP + +# --- Process / system inspection --- +PS +FEATURE_PS_WIDE +DESKTOP +TOP +FEATURE_TOP_INTERACTIVE +FEATURE_TOP_CPU_USAGE_PERCENTAGE +KILL +KILLALL +PIDOF +PGREP +PKILL +FREE +UPTIME +NPROC +DMESG +WATCH + +# --- Text tools --- +GREP +FEATURE_GREP_CONTEXT +FEATURE_GREP_EGREP_ALIAS +EGREP +FGREP +SED +AWK +FIND +FEATURE_FIND_TYPE +FEATURE_FIND_PERM +FEATURE_FIND_MTIME +FEATURE_FIND_NEWER +FEATURE_FIND_EXEC +XARGS +HEXDUMP +OD +STRINGS +LESS +MORE +CMP +DIFF +VI + +# --- Networking (ip is the one command Tailscale shells out to) --- +IP +FEATURE_IP_ADDRESS +FEATURE_IP_LINK +FEATURE_IP_ROUTE +FEATURE_IP_NEIGH +FEATURE_IP_RULE +FEATURE_IP_TUNNEL +IPADDR +IPLINK +IPROUTE +IPNEIGH +IPRULE +PING +PING6 +FEATURE_FANCY_PING +NSLOOKUP +NETSTAT +ARP +ARPING +WGET +TRACEROUTE +FEATURE_IPV6 + +# --- Filesystem --- +MOUNT +UMOUNT +DF +DU +SYNC +LSOF +TAR +GZIP +GUNZIP +ZCAT +FEATURE_SEAMLESS_GZ + +# --- Misc shell conveniences --- +CLEAR +RESET +TTY +SETSID +NOHUP +TIMEOUT +FLOCK