Lab 05 — Tailscale Mesh
Duration: 60 minutes
This lab builds a two-node Tailscale network (tailnet) between your operator
console and the GL.iNet Mango. Both nodes will appear in the tailnet by name,
resolve each other via MagicDNS, and be governed by an ACL policy that
separates operator-side nodes (tag:operator) from device-side nodes
(tag:device). By the end of the lab, tailscale ping drop-<student> from
the operator console returns real latency to the Mango in front of you.
From this point forward, every subsequent lab can reach the Mango over the tailnet rather than via local LAN only. The bench connection is still used for the initial enrollment in this lab; after that, SSH to the Mango can come from any operator console on the tailnet.
Where each step runs
Lab 05 is marked codespaces: "supported" because the Codespace runs the
operator console, and the Mango is enrolled from the laptop over local LAN.
After enrollment completes, the Codespace can reach the Mango by tailnet
hostname for all subsequent steps.
| Step | Bench laptop required | Operator console (Codespace or local Dev Container) |
|---|---|---|
| 1. Confirm ACL | no | yes (confirm with instructor) |
| 2. Record version | yes (Mango SSH) | yes (operator console) |
| 3. Tailscale up on operator console | no | yes |
| 4. Tailscale up on Mango | yes (Mango SSH from laptop) | no |
| 5. MagicDNS verify | no | yes |
| 6. ACL enforcement test | no | yes (Mango side via tailnet SSH) |
| 7. Record manifest pin | no | yes |
| Validation | no | yes |
Codespace users: Step 4 requires SSH to the Mango from your laptop terminal. The Mango is on your local LAN; the Codespace cannot see it directly yet. After the Mango joins the tailnet (end of Step 4), all remaining steps run from the Codespace.
Learning objectives
- Understand Tailscale ACL tags and why
tag:operatorandtag:devicemust be distinct: the operator console can reach drop devices, but drop devices cannot reach other students’ drop devices. - Generate and use ephemeral auth keys with specific ACL tag scopes.
- Bring up Tailscale on two heterogeneous nodes (Debian operator console and MIPS router) using the same CLI pattern.
- Verify MagicDNS resolution and peer presence via
tailscale status --json. - Record the Tailscale version to
labs/output/build-manifest.jsonso all students pin the same version.
Pre-state
Before starting this lab confirm all of the following.
# Lab 03 ExtRoot is working (/overlay is on USB)
ssh root@192.168.8.1 'df -h | grep overlay'
# Expected: /dev/sda1 is /overlay, several GB free
# Tailscale binary is present on the Mango (installed in Lab 03 on ExtRoot)
ssh root@192.168.8.1 'tailscale --version'
# Expected: a version line, e.g. 1.76.6
# Tailscale is present in the operator console
tailscale --version
# Expected: same or close version
# Lab 04 domain is verified; your CF account is active.
# (Lab 05 Tailscale config is independent of CF; they converge in Lab 06.)
# You have the instructor-issued auth keys: one tagged tag:operator (for the
# operator console) and one tagged tag:device (for the Mango).
echo "Have both auth keys in hand before proceeding."
Codespace users:
tailscaleis available in the operator console becausepost-create.shinstalls it via the Cloudflare apt repo and writesTS_USERSPACE=1plusTS_EXTRA_ARGS="--tun=userspace-networking"to/etc/environmentwhen$CODESPACESis set. This meanstailscale upruns in userspace mode and does not require/dev/net/tun. See Tailscale userspace networking for the mechanism.
Local Dev Container users: the operator console Dockerfile installs tailscale via the apt repo. Kernel TUN is available because the Dev Container runs with access to the host’s
/dev/net/tun. No userspace override is needed.
Instructor preparation (complete before class):
- Create the workshop tailnet (free Tailscale account suffices for a class).
- Install the
acl-policy.example.hujsonfrom this lab directory as the tailnet ACL. - Issue one ephemeral auth key per student tagged
tag:device(for the Mango). - Issue one ephemeral auth key per student tagged
tag:operator(for the operator console). - Write both keys to each student’s assignment card.
- Recommended key TTL: 12 hours (expires end of workshop day).
Walkthrough
The ACL policy must be installed on the tailnet before any devices join. The instructor completes this step. Confirm with the instructor that the policy is live before proceeding.
The policy file is labs/lab05-tailscale-mesh/acl-policy.example.hujson.
It defines three tags:
| Tag | Who uses it | What it permits |
|---|---|---|
tag:operator | Operator console (ep-<student>) | Can reach drop devices |
tag:device | Mango (drop-<student>) | Cannot reach other drops |
tag:instructor | Instructor’s node | Can reach all nodes |
The critical rule: tag:operator can initiate connections to tag:device.
Nodes tagged tag:device cannot initiate connections to each other or to
tag:operator. This enforces lateral isolation between student drop devices
on the same workshop tailnet.
Instructor note: to confirm the ACL is active before class, join one test node tagged
tag:deviceand one taggedtag:operator. Verify the operator cantailscale pingthe device but the device cannot initiate back. The ACL blocks connection initiation, not return traffic for sessions the operator already opened.
Pin the version before joining the tailnet so the build manifest records it accurately.
From the operator console:
tailscale --version
Expected output (your exact version may differ):
1.76.6
tailscale commit: abc1234...
go version: go1.22.x
Record the first line (e.g., 1.76.6). You will write this to the
build manifest in Step 7.
Also check the Mango (from your laptop):
ssh root@192.168.8.1 'tailscale --version'
The major.minor version should match the operator console. If the patch version differs, that is acceptable: the MagicDNS and ACL features used in this lab are compatible across minor versions within the same major. Note both versions; both will appear in the manifest.
The operator console is the engagement platform node. Tag it tag:operator.
The Mango gets tag:device in Step 4. The tags must differ for the ACL to
enforce operator-vs-drop separation.
The post-create.sh script wrote TS_EXTRA_ARGS="--tun=userspace-networking"
to /etc/environment when it detected $CODESPACES. Source that file (or
open a new terminal) before running tailscale up:
# Source the env file if not already in your shell
source /etc/environment 2>/dev/null || true
OPERATOR_KEY="tskey-auth-XXXXXXXXXXXX" # replace with your tag:operator key
STUDENT="yourname" # replace with your student slot (e.g. alpha)
tailscale up \
--auth-key="${OPERATOR_KEY}" \
--hostname="ep-${STUDENT}" \
--accept-routes \
--ssh \
${TS_EXTRA_ARGS:-}
The ${TS_EXTRA_ARGS:-} at the end passes --tun=userspace-networking when
the variable is set. On a Codespace this enables userspace mode; on a local
Dev Container the variable is unset and kernel TUN is used.
OPERATOR_KEY="tskey-auth-XXXXXXXXXXXX" # replace with your tag:operator key
STUDENT="yourname" # replace with your student slot (e.g. alpha)
tailscale up \
--auth-key="${OPERATOR_KEY}" \
--hostname="ep-${STUDENT}" \
--accept-routes \
--ssh
Wait a few seconds, then verify:
tailscale status
Expected output (excerpt):
100.x.x.x ep-yourname yourname@ linux -
Confirm the tag was applied:
tailscale status --json | grep -A2 '"Tags"'
# Expected: "Tags": ["tag:operator"]
If Tags is empty or absent, the auth key was not scoped to a tag. Ask the
instructor to re-issue the key with the tag:operator pre-approval set.
This step runs from your laptop terminal. The Mango is on your local LAN; SSH into it and run the equivalent enrollment command using the device auth key.
# From your laptop
ssh root@192.168.8.1
Inside the Mango shell:
# On the Mango
DEVICE_KEY="tskey-auth-YYYYYYYYYYYY" # replace with your tag:device key
STUDENT="yourname" # same slot as Step 3
# Enable and start the tailscale daemon if it is not already running
/etc/init.d/tailscale enable
/etc/init.d/tailscale start
sleep 5
tailscale up \
--auth-key="${DEVICE_KEY}" \
--hostname="drop-${STUDENT}" \
--accept-routes \
--ssh \
--timeout=60s \
${TS_EXTRA_ARGS:-}
The ${TS_EXTRA_ARGS:-} is inherited from the 99-enroll.sh template
(written by post-create.sh to /etc/uci-defaults/). On a Mango, the
variable is normally unset and the flag expands to nothing. The same script
works without modification in either environment.
Wait for the command to return, then check status:
tailscale status
Expected (from the Mango shell):
100.x.x.y drop-yourname yourname@ linux -
100.x.x.x ep-yourname yourname@ linux idle; offers exit node
Both your own nodes should appear as peers. If only one appears, wait 10 to 15 seconds: tailnet peer exchange can lag behind key registration.
Run the following from the operator console. If you are on a Codespace, the Mango is now reachable over the tailnet and no laptop terminal is needed.
# Ping the Mango by MagicDNS name
tailscale ping "drop-${STUDENT}"
Expected output:
pong from drop-yourname (100.x.x.y) via DERP(ord) in 15ms
pong from drop-yourname (100.x.x.y) via 192.168.8.1:41641 in 2ms
The first pong may arrive via a DERP relay before a direct path is established. The second pong confirms a direct peer-to-peer path.
Also verify DNS resolution:
# MagicDNS resolves the short hostname to a tailnet IP
tailscale ip --4 "drop-${STUDENT}"
# Expected: 100.x.x.y (a Tailscale CGNAT address)
# Ping by short name (MagicDNS must be enabled on the tailnet)
ping -c 3 "drop-${STUDENT}"
If DNS does not resolve, MagicDNS may be disabled on the tailnet. The instructor enables it under Tailscale admin console > DNS > Enable MagicDNS.
From the operator console (tag:operator), SSH to the Mango via tailnet:
# This should succeed: operator can reach device
ssh "root@drop-${STUDENT}"
exit
Now test the reverse. From inside the Mango (reachable via the tailnet SSH you just opened, or via local LAN from the laptop):
# On the Mango
# This should fail: device cannot initiate to operator
tailscale ping "ep-${STUDENT}"
# Expected: no route to host, or timeout
# The ACL blocks device -> operator initiation.
The ACL controls connection initiation, not return traffic for sessions the operator already opened. The Mango can reply to SSH sessions the operator console opens; it cannot open new sessions to the operator console.
Cross-student isolation works the same way: your drop-yourname
(tag:device) cannot reach another student’s drop-beta (tag:device)
because tag:device is not listed as a source in any accept rule targeting
tag:device. The instructor can demonstrate this on the shared display.
The build manifest accumulates version pins across labs. Lab 05 adds
tailscale_version.
From the operator console:
MANIFEST="labs/output/build-manifest.json"
TS_VERSION=$(tailscale --version | head -1)
# If the manifest does not exist yet, create a minimal stub
if [ ! -f "$MANIFEST" ]; then
mkdir -p labs/output
printf '{"role":"engagement-platform","openwrt_version":"23.05.3","created_at":"%s"}\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$MANIFEST"
fi
# Merge tailscale_version into the manifest
UPDATED=$(jq --arg v "$TS_VERSION" '. + {tailscale_version: $v}' "$MANIFEST")
printf '%s\n' "$UPDATED" > "$MANIFEST"
echo "tailscale_version recorded: $TS_VERSION"
cat "$MANIFEST"
The manifest now contains "tailscale_version": "1.76.6" (or your version).
Two students building on the same day should see the same version because the
operator console Dockerfile pins the tailscale apt package. If versions differ
across students, the instructor should verify the apt pin in the Dockerfile;
see the further reading section for the version-pinning take-home extension.
Post-state
When this lab is complete you should be able to answer yes to all of the following:
-
tailscale status --jsonon the operator console showsep-<student>withtag:operatorand listsdrop-<student>as a peer. -
tailscale status --jsonon the Mango showsdrop-<student>withtag:deviceand listsep-<student>as a peer. -
tailscale ping drop-<student>from the operator console returns pong with latency. -
ssh root@drop-<student>from the operator console succeeds over the tailnet. -
tailscale ping ep-<student>from the Mango fails or is refused (ACL blocks device-to-operator initiation). -
labs/output/build-manifest.jsoncontains atailscale_versionfield.
Validation
Three validation paths. All three call the same validate.sh; only the
delivery channel differs.
Path 1: epl CLI (recommended from the operator console).
export STUDENT=yourname
epl validate lab05-tailscale-mesh
The validator SSHes to the Mango via the tailnet (using the drop-<student>
hostname), so no bench connection is required after Step 4. Re-run as many
times as you like; only the most recent result is recorded.
Path 2: direct script.
export STUDENT=yourname
bash courses/engagement-platform-labs/labs/lab05-tailscale-mesh/validate.sh
Or via the Makefile:
cd courses/engagement-platform-labs/labs
make validate-lab05-tailscale-mesh
Path 3: paste output into the widget.
Run validate.sh anywhere, copy the full output, and paste it into the
widget below.
What the script checks:
tailscale status --jsonon the operator console showsep-<student>in Self withtag:operator.ep-<student>appears as a peer in the Mango’s status output (via SSH).drop-<student>appears as a peer in the operator console’s status output.tailscale status --jsonon the Mango showsdrop-<student>in Self withtag:device.tailscale ping drop-<student>from the operator console returns pong.labs/output/build-manifest.jsoncontainstailscale_version.
The script reads STUDENT from the environment. Export it before running:
export STUDENT=yourname
Primitives and drop-in substitutes
Tailscale is this lab’s specific mesh implementation. The primitive it delivers decomposes into four substitutable layers. WireGuard is the data plane and the de-facto standard across the mesh-VPN landscape. The control plane governs who belongs to the mesh and what each peer can reach. The relay plane provides a TCP-based fallback when direct UDP between peers fails. MagicDNS-style name resolution is the fourth, optional, layer that maps peer hostnames to tailnet addresses. If a client network blocks Tailscale specifically, the WireGuard data plane usually continues to work; what you swap is one of the upper layers.
┌───────────────┐
PEERS ←────→ │ DATA PLANE │ ← WireGuard (kernel module or userspace)
└───────────────┘
↑
│ keys, peer list, ACL
↓
┌───────────────┐
│ CONTROL PLANE │ ← Tailscale coordinator | headscale | Innernet | NetBird
└───────────────┘
↑
│ when direct UDP fails
↓
┌───────────────┐
│ RELAY PLANE │ ← DERP (Tailscale) | self-hosted DERP | Nebula lighthouse
└───────────────┘
↑
│ optional
↓
┌───────────────┐
│ NAME SERVICE │ ← MagicDNS | headscale DNS | split-DNS resolver
└───────────────┘
Substitutes
| Name | Layer covered | License | When to consider it |
|---|---|---|---|
| WireGuard (raw kernel module + wireguard-tools) | data plane only | GPL-2.0 (kernel module); Apache-2.0 (wireguard-tools) | full manual control of keys and routing; configure peer lists yourself or via a tool below |
| wireguard-go | data plane only (userspace Go implementation) | MIT | when the kernel module is unavailable; same protocol, slower throughput |
| boringtun (Cloudflare) | data plane only (userspace Rust implementation) | BSD-3-Clause | Tailscale uses this on platforms without kernel WireGuard; standalone when you want userspace without Go |
| headscale | control plane (Tailscale-compatible coordinator) | BSD-3-Clause | run your own coordinator; stock Tailscale clients point at your headscale URL; ACL model is hujson-compatible |
| Innernet (tonarino) | control plane + data plane (WireGuard) | MIT | self-hosted; CIDR-aware ACLs; the simplest path to a fully self-contained mesh |
| Nebula (Slack/Defined Networking) | control plane + data plane (Noise Protocol, not WireGuard) | MIT | certificate-based identity; lighthouse nodes replace DERP; different trust model from Tailscale; used at scale internally at Slack |
| NetBird | control plane + data plane (WireGuard) | AGPLv3 (server components: management, signal, relay); BSD-3-Clause (clients) | self-hosted Tailscale-like coordinator with built-in SSO; note license split as of v0.53 (Aug 2025) |
| ZeroTier | control plane + data plane (proprietary L2-over-UDP) | BSL-1.1 | Layer-2 emulation across the overlay; older projects already using it; check the BSL commercial-use terms before adopting |
| Defined Networking (Managed Nebula) | hosted control plane for Nebula | proprietary | run Nebula without operating your own CA or lighthouse; Defined Networking handles enrollment and certificate rotation |
| OpenZiti | control plane + data plane (mTLS over TCP/UDP) | Apache-2.0 | identity-first; all traffic is application-layer; different shape from WireGuard mesh; NetFoundry sponsors ongoing development |
| Cloudflare WARP (client app) | userspace WireGuard client to Cloudflare gateway | proprietary | near-miss: routes client traffic to Cloudflare’s edge, not peer-to-peer; useful as a client-egress primitive, not a mesh substitute |
| OpenVPN | data plane + simple control | GPL-2.0 | pre-WireGuard era; cite as a historical reference when auditing legacy configurations, not as a recommended substitute |
Take-home prompt
Stand up headscale on a VPS or a local systemd-nspawn container, reconfigure your operator console’s tailscaled to point at the headscale URL, and re-run the Lab 05 enrollment steps against your own coordinator. Compare the user model: Tailscale’s OAuth-backed admin console and ACL hujson versus headscale’s CLI-managed users and namespaces. For an alternative track, replace the data plane entirely: generate raw WireGuard keypairs with wg genkey / wg pubkey, write a minimal wg-quick config for each peer, bring up the interface with wg-quick up, and verify direct reachability without any coordinator.
Further reading
Tailscale upstream:
- Tailscale userspace networking. The mode the Codespace path uses. Explains when userspace is needed, the performance trade-off versus kernel TUN, and how to opt out on a machine where kernel TUN is available.
- ACL tags. The mechanism behind
tag:operatorandtag:device. Covers pre-approved tags, tag owners, and how to build multi-tier ACL policies for more complex engagement architectures. - MagicDNS. How Tailscale’s internal DNS resolver works,
how to enable it, and the split-DNS behavior that makes short hostnames
(
drop-yourname) resolve inside the tailnet without affecting external DNS. - Auth keys. Ephemeral vs reusable keys, pre-approved tags, and recommended TTL for workshop environments.
Workshop-internal:
- Lab 03: Overlay Deployment. Tailscale was
installed on the Mango ExtRoot in Lab 03. If the
tailscale --versioncheck in the pre-state above fails, verify that Lab 03 is complete and the USB overlay is mounted. - Lab 06: Cloudflare Tunnel. Alternative reachability path: cloudflared creates an outbound-only HTTPS tunnel from the Mango to Cloudflare’s network without any firewall rule or port forward. Lab 05 (Tailscale) and Lab 06 (cloudflared) are complementary; neither replaces the other.
View this page’s source:
labs/lab05-tailscale-mesh/README.mdx
Troubleshooting
tailscale up returns “node is not pre-approved”
The auth key was created without a pre-approved ACL tag. The instructor must:
- Go to Tailscale admin console > Settings > Auth keys.
- Delete the issued key.
- Create a new key, click “Add tags”, select the correct tag (
tag:operatorortag:device), and confirm the tag is in the “Pre-approved” section. - Re-issue the key to the student.
tailscale up hangs on the Mango
The Mango has 128 MB RAM and the tailscale daemon can be slow to start.
# On the Mango
/etc/init.d/tailscale restart
sleep 10
tailscale status
If the daemon is not running:
logread | grep tailscale
# Look for OOM or missing module errors
If OOM errors appear, stop any other daemons temporarily:
/etc/init.d/uhttpd stop is safe during this lab.
MagicDNS resolution fails (ping: bad address)
MagicDNS must be enabled on the tailnet in the Tailscale admin console. Confirm with the instructor. Also confirm that the tailscale daemon is the system DNS resolver. On the operator console:
cat /etc/resolv.conf
# Should show 100.100.100.100 as the first nameserver
If /etc/resolv.conf points elsewhere, tailscale may have started without
DNS control:
tailscale up --accept-dns=true <other-flags>
If the container’s /etc/resolv.conf cannot be overwritten, resolve using
explicit IP:
DROP_IP=$(tailscale ip --4 "drop-${STUDENT}")
ping -c 3 "$DROP_IP"
ACL test shows device CAN reach operator (expected to fail)
The ACL policy may not be installed, or may have a permissive default
("*": ["*"]). Ask the instructor to re-verify the tailnet ACL matches
acl-policy.example.hujson. The key rule is that tag:device is not listed
as a source in any accept rule targeting tag:operator.
A freshly created tailnet has a default ACL that allows all traffic. The workshop ACL must be applied before this test is meaningful.
Both nodes appear in status but pong shows only DERP, no direct path
A direct peer-to-peer path requires at least one side to have a reachable UDP port. In a workshop environment with double-NAT, DERP relay is expected and fully functional. The direct path is an optimization. If it is required for a specific exercise, connect both the Mango and the laptop to the same local network segment.
Codespace: tailscale up fails with “dev/net/tun: no such file”
The post-create.sh script sets TS_EXTRA_ARGS="--tun=userspace-networking"
when $CODESPACES is set. Verify the variable is present in your shell:
echo "$TS_EXTRA_ARGS"
# Expected: --tun=userspace-networking
If it is empty, source /etc/environment and try again:
source /etc/environment
tailscale up --tun=userspace-networking <other-flags>
ValidateOutputPaster lab="lab05")