Dev mode — no Cloudflare Access header present. Authenticated as dev@local.
EPL
dev@local

Lab 05 · Day 1, Session 5

Tailscale Mesh

Duration: 60 minutes

Not started
Hardware profile:
IP: 192.168.8.1 Password: goodlife
Environment:

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.

StepBench laptop requiredOperator console (Codespace or local Dev Container)
1. Confirm ACLnoyes (confirm with instructor)
2. Record versionyes (Mango SSH)yes (operator console)
3. Tailscale up on operator consolenoyes
4. Tailscale up on Mangoyes (Mango SSH from laptop)no
5. MagicDNS verifynoyes
6. ACL enforcement testnoyes (Mango side via tailnet SSH)
7. Record manifest pinnoyes
Validationnoyes

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:operator and tag:device must 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.json so 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: tailscale is available in the operator console because post-create.sh installs it via the Cloudflare apt repo and writes TS_USERSPACE=1 plus TS_EXTRA_ARGS="--tun=userspace-networking" to /etc/environment when $CODESPACES is set. This means tailscale up runs 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):

  1. Create the workshop tailnet (free Tailscale account suffices for a class).
  2. Install the acl-policy.example.hujson from this lab directory as the tailnet ACL.
  3. Issue one ephemeral auth key per student tagged tag:device (for the Mango).
  4. Issue one ephemeral auth key per student tagged tag:operator (for the operator console).
  5. Write both keys to each student’s assignment card.
  6. 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:

TagWho uses itWhat it permits
tag:operatorOperator console (ep-<student>)Can reach drop devices
tag:deviceMango (drop-<student>)Cannot reach other drops
tag:instructorInstructor’s nodeCan 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:device and one tagged tag:operator. Verify the operator can tailscale ping the 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 --json on the operator console shows ep-<student> with tag:operator and lists drop-<student> as a peer.
  • tailscale status --json on the Mango shows drop-<student> with tag:device and lists ep-<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.json contains a tailscale_version field.

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:

  1. tailscale status --json on the operator console shows ep-<student> in Self with tag:operator.
  2. ep-<student> appears as a peer in the Mango’s status output (via SSH).
  3. drop-<student> appears as a peer in the operator console’s status output.
  4. tailscale status --json on the Mango shows drop-<student> in Self with tag:device.
  5. tailscale ping drop-<student> from the operator console returns pong.
  6. labs/output/build-manifest.json contains tailscale_version.

The script reads STUDENT from the environment. Export it before running:

export STUDENT=yourname
Validate Output Paste your validate.sh output below

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

NameLayer coveredLicenseWhen to consider it
WireGuard (raw kernel module + wireguard-tools)data plane onlyGPL-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-godata plane only (userspace Go implementation)MITwhen the kernel module is unavailable; same protocol, slower throughput
boringtun (Cloudflare)data plane only (userspace Rust implementation)BSD-3-ClauseTailscale uses this on platforms without kernel WireGuard; standalone when you want userspace without Go
headscalecontrol plane (Tailscale-compatible coordinator)BSD-3-Clauserun your own coordinator; stock Tailscale clients point at your headscale URL; ACL model is hujson-compatible
Innernet (tonarino)control plane + data plane (WireGuard)MITself-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)MITcertificate-based identity; lighthouse nodes replace DERP; different trust model from Tailscale; used at scale internally at Slack
NetBirdcontrol 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)
ZeroTiercontrol plane + data plane (proprietary L2-over-UDP)BSL-1.1Layer-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 Nebulaproprietaryrun Nebula without operating your own CA or lighthouse; Defined Networking handles enrollment and certificate rotation
OpenZiticontrol plane + data plane (mTLS over TCP/UDP)Apache-2.0identity-first; all traffic is application-layer; different shape from WireGuard mesh; NetFoundry sponsors ongoing development
Cloudflare WARP (client app)userspace WireGuard client to Cloudflare gatewayproprietarynear-miss: routes client traffic to Cloudflare’s edge, not peer-to-peer; useful as a client-egress primitive, not a mesh substitute
OpenVPNdata plane + simple controlGPL-2.0pre-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:operator and tag: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 --version check 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:

  1. Go to Tailscale admin console > Settings > Auth keys.
  2. Delete the issued key.
  3. Create a new key, click “Add tags”, select the correct tag (tag:operator or tag:device), and confirm the tag is in the “Pre-approved” section.
  4. 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>
Validate output paster — available in Wave 2D (ValidateOutputPaster lab="lab05")
Downloadable artifacts for lab05 — served from R2 after Wave 3B deployment