diff --git a/constants/labels.go b/constants/labels.go index 6db74c5068..cd5f4333d0 100644 --- a/constants/labels.go +++ b/constants/labels.go @@ -12,4 +12,6 @@ const ( NodeMgmtNetBr = "clab-mgmt-net-bridge" Owner = "clab-owner" ToolType = "tool-type" + // IsInfrastructure marks containers that are infrastructure components (not user nodes) + IsInfrastructure = "clab-is-infrastructure" ) diff --git a/core/clab.go b/core/clab.go index 5118b7e727..86fc0d0cd3 100644 --- a/core/clab.go +++ b/core/clab.go @@ -702,3 +702,69 @@ func (c *CLab) CheckConnectivity(ctx context.Context) error { return nil } + +// DeployInfrastructure deploys infrastructure components like Tailscale VPN +// after the network is created but before nodes are deployed. +func (c *CLab) DeployInfrastructure(ctx context.Context) error { + // Get the docker runtime and deploy infrastructure + if dockerRuntime, ok := c.Runtimes[clabruntimedocker.RuntimeName]; ok { + if dr, ok := dockerRuntime.(*clabruntimedocker.DockerRuntime); ok { + // Create LabContext with the necessary information + labCtx := &clabruntimedocker.LabContext{ + Name: c.Config.Name, + Prefix: *c.Config.Prefix, + Owner: clabutils.GetOwner(), + LabDir: c.TopoPaths.TopologyLabDir(), + TopoFile: c.TopoPaths.TopologyFilenameAbsPath(), + } + + // Call DeployTailscale with the lab context + // DeployTailscale will check if Tailscale is configured and enabled + if err := dr.DeployTailscale(ctx, labCtx); err != nil { + return fmt.Errorf("failed to deploy Tailscale: %w", err) + } + } + } + + return nil +} + +// DestroyInfrastructure removes infrastructure components like Tailscale VPN. +func (c *CLab) DestroyInfrastructure(ctx context.Context) error { + // Get the docker runtime and destroy infrastructure + if dockerRuntime, ok := c.Runtimes[clabruntimedocker.RuntimeName]; ok { + if dr, ok := dockerRuntime.(*clabruntimedocker.DockerRuntime); ok { + + // Call DestroyTailscale with the lab name + // DestroyTailscale will check if Tailscale is configured and enabled + // We don't return errors here to allow cleanup to continue + if err := dr.DestroyTailscale(ctx, c.Config.Name); err != nil { + log.Warnf("errors during Tailscale destruction: %v", err) + } + } + } + + return nil +} + +// UpdateInfrastructureDNS updates DNS records in infrastructure components (like Tailscale DNS). +// This should be called after nodes are deployed to populate DNS with node information. +func (c *CLab) UpdateInfrastructureDNS(ctx context.Context) error { + // Get the docker runtime and update DNS + if dockerRuntime, ok := c.Runtimes[clabruntimedocker.RuntimeName]; ok { + if dr, ok := dockerRuntime.(*clabruntimedocker.DockerRuntime); ok { + // Convert Nodes map to interface{} map for UpdateTailscaleDNS + nodesMap := make(map[string]interface{}) + for name, node := range c.Nodes { + nodesMap[name] = node + } + + // Update Tailscale DNS with node records + if err := dr.UpdateTailscaleDNS(ctx, c.Config.Name, nodesMap); err != nil { + log.Warnf("Failed to update Tailscale DNS: %v", err) + } + } + } + + return nil +} diff --git a/core/config.go b/core/config.go index a751395eff..fe9b95749e 100644 --- a/core/config.go +++ b/core/config.go @@ -572,6 +572,10 @@ func (c *CLab) verifyContainersUniqueness(ctx context.Context) error { // the lab name of a currently deploying lab // this ensures lab uniqueness for idx := range containers { + // Skip infrastructure containers (e.g., Tailscale, future VPN/DNS/proxy containers) + if containers[idx].Labels[clabconstants.IsInfrastructure] == "true" { + continue + } if containers[idx].Labels[clabconstants.Containerlab] == c.Config.Name { return fmt.Errorf( "the '%s' lab has already been deployed. Destroy the lab before deploying a "+ diff --git a/core/deploy.go b/core/deploy.go index 5733614d5e..9a4b741857 100644 --- a/core/deploy.go +++ b/core/deploy.go @@ -44,6 +44,11 @@ func (c *CLab) Deploy( //nolint: funlen return nil, err } + // Deploy infrastructure components (like Tailscale) after network creation but before nodes + if err := c.DeployInfrastructure(ctx); err != nil { + return nil, err + } + err = clablinks.SetMgmtNetUnderlyingBridge(c.Config.Mgmt.Bridge) if err != nil { return nil, err @@ -151,6 +156,11 @@ func (c *CLab) Deploy( //nolint: funlen execCollection.Log() + // Update infrastructure DNS with deployed node records + if err := c.UpdateInfrastructureDNS(ctx); err != nil { + log.Warnf("Failed to update infrastructure DNS: %v", err) + } + if err := c.GenerateInventories(); err != nil { return nil, err } diff --git a/core/destroy.go b/core/destroy.go index a6e13e28a9..db35043779 100644 --- a/core/destroy.go +++ b/core/destroy.go @@ -274,6 +274,11 @@ func (c *CLab) destroy(ctx context.Context, maxWorkers uint, keepMgmtNet bool) e } } + // destroy infrastructure components (like Tailscale) before deleting the network + if err := c.DestroyInfrastructure(ctx); err != nil { + log.Warnf("errors during infrastructure destruction: %v", err) + } + // delete lab management network if c.Config.Mgmt.Network != "bridge" && !keepMgmtNet { log.Debugf("Calling DeleteNet method. *CLab.Config.Mgmt value is: %+v", c.Config.Mgmt) diff --git a/docs/manual/network.md b/docs/manual/network.md index 0d795c23a7..97a036099f 100644 --- a/docs/manual/network.md +++ b/docs/manual/network.md @@ -298,6 +298,21 @@ topology: 1. When set to `false`, containerlab will not touch iptables rules. On most docker installations this will result in restricted external access. +#### Tailscale VPN + +Containerlab supports automatic deployment of [Tailscale](https://tailscale.com/) VPN to enable secure remote access to your lab's management network. Simply add a `tailscale` section under your management network configuration with your Tailscale auth key, and containerlab will automatically deploy and configure a Tailscale container that advertises your management network routes. + +```yaml +name: mylab +mgmt: + network: clab + ipv4-subnet: 172.20.20.0/24 + tailscale: + authkey: "tskey-auth-xxxxx-yyyyy" +``` + +For comprehensive documentation including advanced configuration options, 1:1 NAT support, security considerations, and troubleshooting, see the [Tailscale VPN guide](tailscale.md) + ///details | Errors and warnings External access feature requires nftables kernel API to be present. Kernels newer than v4 typically have this API enabled by default. To understand which API is in use one can issue the following command: diff --git a/docs/manual/tailscale.md b/docs/manual/tailscale.md new file mode 100644 index 0000000000..30cbaa84c4 --- /dev/null +++ b/docs/manual/tailscale.md @@ -0,0 +1,798 @@ +# Tailscale VPN Integration + +Containerlab provides built-in support for [Tailscale](https://tailscale.com/) VPN to enable secure remote access to your lab's management network. This integration automatically deploys and configures a Tailscale container that connects your lab to your Tailscale network. + +## Overview + +When Tailscale is enabled, containerlab will: + +1. Deploy a Tailscale container as infrastructure (not a lab node) +2. Advertise your management network routes to your Tailscale network +3. Configure the container with the appropriate IP address and labels +4. Manage the lifecycle of the Tailscale container alongside your lab + +This allows you to access your lab nodes from anywhere, securely through Tailscale's encrypted mesh network. + +## Prerequisites + +Before using Tailscale with containerlab, you need: + +1. **A Tailscale account** - Sign up at [https://login.tailscale.com/start](https://login.tailscale.com/start) +2. **An auth key** - Generate one in your [Tailscale admin console](https://login.tailscale.com/admin/settings/keys) + - For labs, create a **reusable** auth key + - Enable **route advertisement** in the key settings + - Consider using **tagged keys** for better ACL control + - Set an appropriate expiration time + +## Quick Start + +Add the `tailscale` section to your topology file: + +```yaml +name: my-lab +mgmt: + network: clab + ipv4-subnet: 172.20.20.0/24 + tailscale: + authkey: "tskey-auth-k1234567890CNTRL-abcdefghijklmnop" +``` + +Deploy your lab: + +```bash +sudo containerlab deploy -t my-lab.clab.yml +``` + +Accept the advertised routes in your [Tailscale admin console](https://login.tailscale.com/admin/machines), then access your lab nodes: + +```bash +# SSH to a node using its management IP +ssh admin@172.20.20.5 + +# Ping a node +ping 172.20.20.5 +``` + +## Configuration Reference + +### Minimal Configuration + +```yaml +mgmt: + tailscale: + authkey: "tskey-auth-xxxxx" +``` + +### Full Configuration + +```yaml +mgmt: + network: clab + ipv4-subnet: 172.20.20.0/24 + ipv6-subnet: 2001:172:20:20::/64 + tailscale: + enabled: true + authkey: "tskey-auth-xxxxx" + image: "tailscale/tailscale:v1.56.0" + ipv4-address: "172.20.20.254" + ipv6-address: "2001:172:20:20::fffe" + tags: + - "lab-access" + - "team:engineering" + snat: false + accept-routes: false + accept-dns: false + one-to-one-nat: "10.0.0.0/24" + ephemeral-state: true +``` + +### Configuration Options + +#### enabled + +**Type:** `boolean` +**Default:** `true` (when `tailscale` section exists) + +Explicitly enable or disable Tailscale deployment. When the `tailscale` section is present, Tailscale is enabled by default. + +```yaml +tailscale: + enabled: false # Disable even though section exists + authkey: "tskey-auth-xxxxx" +``` + +#### authkey + +**Type:** `string` +**Required:** Yes + +Your Tailscale authentication key. This is the only required field. + +```yaml +tailscale: + authkey: "tskey-auth-k1234567890CNTRL-abcdefghijklmnop" +``` + +!!! tip "Auth Key Best Practices" + - Use **reusable** keys for labs that are deployed/destroyed frequently + - Use **tagged** keys for better ACL control + - Enable **route advertisement** in the key settings + - Set reasonable expiration times + - Store keys securely, consider using environment variables: + ```yaml + authkey: "${TAILSCALE_KEY}" + ``` + +#### image + +**Type:** `string` +**Default:** `tailscale/tailscale:latest` + +Docker image to use for the Tailscale container. Specify a version tag for reproducibility. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + image: "tailscale/tailscale:v1.56.0" +``` + +#### ipv4-address + +**Type:** `string` +**Default:** Last usable IP in the IPv4 subnet (excludes broadcast) + +Custom IPv4 address for the Tailscale container. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + ipv4-address: "172.20.20.254" +``` + +!!! note + The address must be within the management `ipv4-subnet` range. + +#### ipv6-address + +**Type:** `string` +**Default:** Last IP in the IPv6 subnet + +Custom IPv6 address for the Tailscale container. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + ipv6-address: "2001:172:20:20::fffe" +``` + +#### tags + +**Type:** `list of strings` +**Default:** `[]` (no tags) + +Tailscale ACL tags to apply to the machine. Tags must be prefixed with `tag:` or the prefix will be added automatically. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + tags: + - "lab" # Will become "tag:lab" + - "tag:production" # Already prefixed +``` + +Use tags in your [Tailscale ACL](https://tailscale.com/kb/1018/acls/) to control access: + +```json +{ + "tagOwners": { + "tag:lab": ["group:developers"], + "tag:production": ["group:admins"] + }, + "acls": [ + { + "action": "accept", + "src": ["group:developers"], + "dst": ["tag:lab:*"] + } + ] +} +``` + +#### snat + +**Type:** `boolean` +**Default:** `true` + +Enable or disable source NAT for traffic originating from Tailscale. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + snat: false +``` + +#### accept-routes + +**Type:** `boolean` +**Default:** `false` + +Accept subnet routes advertised by other nodes in your Tailscale network. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + accept-routes: true +``` + +#### accept-dns + +**Type:** `boolean` +**Default:** `false` + +Accept DNS configuration from Tailscale MagicDNS. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + accept-dns: true +``` + +#### one-to-one-nat + +**Type:** `string` (CIDR notation) +**Default:** `""` (disabled) + +Advertise a different subnet via Tailscale with 1:1 NAT mapping to the actual management network. This is useful when: + +- The management subnet overlaps with other networks +- You want a stable IP range across lab redeployments +- You need to integrate with existing addressing schemes + +```yaml +mgmt: + ipv4-subnet: 172.20.20.0/24 # Actual management network + tailscale: + authkey: "tskey-auth-xxxxx" + one-to-one-nat: "10.0.0.0/24" # Advertised subnet +``` + +With this configuration: +- Node at `172.20.20.5` → accessible via `10.0.0.5` +- Node at `172.20.20.10` → accessible via `10.0.0.10` +- Tailscale container at `172.20.20.254` → accessible via `10.0.0.254` + +**Requirements:** +- Source and destination subnets must be the same size +- Both must have the same prefix length +- Only IPv4 is supported for NAT + +##### DNS Doctoring with NAT + +When both NAT and DNS are enabled together, ContainerLab automatically sets up **DNS response rewriting** (DNS doctoring) to ensure DNS queries return the correct IP addresses based on the client's location: + +```yaml +mgmt: + ipv4-subnet: 172.20.20.0/24 + tailscale: + authkey: "tskey-auth-xxxxx" + one-to-one-nat: "172.20.200.0/24" + dns: + enabled: true +``` + +**How it works:** + +1. A lightweight DNS proxy sits in front of CoreDNS +2. Queries from **Tailscale clients** (100.x.x.x IPs) get responses with **NAT IPs** +3. Queries from **local network** get responses with **real management IPs** + +**Example:** + +```bash +# From Tailscale client +$ nslookup node1.mylab.clab 172.20.200.254 +Address: 172.20.200.5 # ← NAT IP (translated) + +# From lab host (local network) +$ nslookup node1.mylab.clab 172.20.20.254 +Address: 172.20.20.5 # ← Real management IP +``` + +**Architecture:** + +``` +Tailscale Client → DNS Proxy (port 53) → CoreDNS (port 5353) + ↓ + Rewrites IPs based on client source: + - From 100.x.x.x → return NAT IPs + - From local net → return real IPs +``` + +The DNS proxy automatically: +- Detects query source (Tailscale vs local) +- Translates A record responses for Tailscale clients +- Preserves IPv6 addresses unchanged +- Works transparently with MagicDNS + +**Note:** DNS doctoring is only active when **both** `one-to-one-nat` **and** `dns.enabled` are configured. With NAT only (no DNS), you access nodes directly via NAT IPs. + +#### ephemeral-state + +**Type:** `boolean` +**Default:** `false` + +Use ephemeral/in-memory state instead of persisting Tailscale state to disk. When enabled: + +- Tailscale runs with `TS_STATE_DIR=mem:` +- Device is automatically removed from your Tailscale network when the container stops +- No persistent state is stored in `/var/lib/tailscale` + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + ephemeral-state: true +``` + +**When to use:** +- Temporary or frequently recreated labs +- CI/CD environments +- Testing scenarios +- When you don't want devices accumulating in your Tailscale admin console + +**When NOT to use:** +- Production labs that should persist in your Tailscale network +- When you need to preserve Tailscale state across container restarts +- When debugging requires consistent device identity + +!!! tip "Auth Keys with Ephemeral State" + When using `ephemeral-state: true`, make sure your auth key is: + + - **Reusable** - so the same key can be used each time the lab is deployed + +## Use Cases + +### Remote Lab Access + +Enable remote team members to access lab infrastructure securely: + +```yaml +name: team-lab +mgmt: + network: clab + ipv4-subnet: 172.20.20.0/24 + tailscale: + authkey: "tskey-auth-xxxxx" + tags: + - "team-shared" +``` + +### Multi-Site Labs + +Connect labs across different locations or cloud providers: + +```yaml +name: site-a +mgmt: + tailscale: + authkey: "tskey-auth-xxxxx" + one-to-one-nat: "10.100.0.0/24" + tags: + - "site-a" + +--- +name: site-b +mgmt: + tailscale: + authkey: "tskey-auth-xxxxx" + one-to-one-nat: "10.200.0.0/24" + tags: + - "site-b" +``` + +### Development and CI/CD + +Provide developers secure access to ephemeral test environments: + +```yaml +name: ci-test-${BUILD_ID} +mgmt: + tailscale: + authkey: "${TAILSCALE_CI_KEY}" + tags: + - "ci" + - "temporary" +``` + +## MagicDNS and Split DNS + +Containerlab supports running a DNS server inside the Tailscale container to enable DNS resolution of lab nodes using FQDNs like `node-name.clab`. This integrates with Tailscale's MagicDNS feature to provide seamless name resolution across your tailnet. + +### How It Works + +When DNS is enabled: + +1. **CoreDNS runs in the Tailscale container** - A DNS server is automatically installed and configured +2. **Node records are automatically generated** - After lab deployment, DNS records are created for all nodes +3. **Split DNS via Tailscale MagicDNS** - Configure Tailscale to use the containerlab DNS for `..clab` domains +4. **FQDN access** - Access nodes using `..clab` from anywhere on your tailnet + +### Configuration + +#### Step 1: Enable DNS in Containerlab + +Add the `dns` section to your Tailscale configuration: + +```yaml +name: my-lab +mgmt: + network: clab + ipv4-subnet: 172.20.20.0/24 + tailscale: + authkey: "tskey-auth-xxxxx" + dns: + enabled: true + domain: "my-lab.clab" # optional, defaults to ".clab" + port: 53 # optional, defaults to 53 + coredns-version: "1.13.1" # optional, defaults to "1.13.1" +``` + +#### Step 2: Configure Tailscale MagicDNS + +1. **Enable MagicDNS** in your [Tailscale admin console](https://login.tailscale.com/admin/dns): + - Navigate to DNS settings + - Enable "MagicDNS" + +2. **Add Split DNS nameserver**: + - Under "Nameservers" → "Split DNS" + - Add a nameserver entry: + - **Domain suffix**: `my-lab.clab` (or your custom domain) + - **Nameservers**: The Tailscale container's IP address + - **Without 1:1 NAT**: Use the real management IP (e.g., `172.20.20.254`) + - **With 1:1 NAT**: Use the NAT IP (e.g., `172.20.200.254` or `10.0.0.254`) + + **Important for NAT users:** If you configured `one-to-one-nat`, use the **NAT IP** in MagicDNS settings, not the real management IP. The DNS proxy will automatically return NAT-translated IPs to Tailscale clients. + +3. **Deploy your lab**: + + ```bash + sudo containerlab deploy -t my-lab.clab.yml + ``` + +#### Step 3: Test DNS Resolution + +From any device connected to your Tailscale network: + +```bash +# Resolve a node by its short name (assuming lab name is "my-lab") +ping node1.my-lab.clab + +# SSH using FQDN +ssh admin@router1.my-lab.clab + +# Works with both IPv4 and IPv6 +ping6 switch1.my-lab.clab +``` + +### DNS Configuration Options + +#### dns.enabled + +**Type:** `boolean` +**Default:** `false` + +Enable DNS server in the Tailscale container. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + dns: + enabled: true +``` + +#### dns.domain + +**Type:** `string` +**Default:** `.clab` + +DNS domain suffix for containerlab nodes. Nodes will be accessible as `.`. + +By default, the domain is `.clab`. For a lab named `my-lab`, nodes will be `node1.my-lab.clab`. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + dns: + enabled: true + domain: "mylab.local" # Override default - nodes accessible as node1.mylab.local +``` + +#### dns.port + +**Type:** `integer` +**Default:** `53` + +DNS server listen port. Usually doesn't need to be changed. + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + dns: + enabled: true + port: 5353 # custom port +``` + +#### dns.coredns-version + +**Type:** `string` +**Default:** `1.13.1` + +CoreDNS version to install in the Tailscale container. Specify a version number (without the 'v' prefix). + +```yaml +tailscale: + authkey: "tskey-auth-xxxxx" + dns: + enabled: true + coredns-version: "1.13.1" # or any other CoreDNS version +``` + +### DNS Record Format + +Containerlab automatically generates DNS records for all nodes: + +- **Format**: `..clab` (or `.` if domain is overridden) +- **Example**: For a lab named `my-lab` with a node shortname `router1`, the FQDN is `router1.my-lab.clab` +- **Both IPv4 and IPv6**: If a node has both IPv4 and IPv6 management addresses, both A and AAAA records are created + +**Example DNS Records (lab name: `my-lab`):** + +| Node ShortName | Management IPv4 | Management IPv6 | FQDN | +|----------------|----------------|----------------|------| +| router1 | 172.20.20.10 | 3fff:172:20:20::10 | router1.my-lab.clab | +| switch1 | 172.20.20.11 | 3fff:172:20:20::11 | switch1.my-lab.clab | +| server1 | 172.20.20.12 | - | server1.my-lab.clab | + +### Complete Example + +```yaml +name: dns-lab +mgmt: + network: clab + ipv4-subnet: 172.20.20.0/24 + ipv6-subnet: 3fff:172:20:20::/64 + tailscale: + authkey: "tskey-auth-xxxxx" + tags: + - "lab-dns" + dns: + enabled: true + domain: "clab" + +topology: + nodes: + router1: + kind: nokia_srlinux + type: ixrd3 + router2: + kind: nokia_srlinux + type: ixrd3 + switch1: + kind: nokia_srlinux + type: ixrd2 + links: + - endpoints: ["router1:e1-1", "router2:e1-1"] + - endpoints: ["router1:e1-2", "switch1:e1-1"] +``` + +After deployment: + +```bash +# From your laptop (connected to Tailscale) +$ ping router1.dns-lab.clab +PING router1.dns-lab.clab (172.20.20.10): 56 data bytes +64 bytes from 172.20.20.10: icmp_seq=0 ttl=64 time=45.2 ms + +$ ssh admin@switch1.dns-lab.clab +Warning: Permanently added 'switch1.dns-lab.clab,172.20.20.12' to the list of known hosts. +admin@switch1:~$ +``` + +### Troubleshooting DNS + +#### Check DNS Server Status + +```bash +# Check if CoreDNS is running +docker exec clab-mylab-tailscale ps aux | grep coredns + +# View CoreDNS logs +docker logs clab-mylab-tailscale 2>&1 | grep "coredns:" + +# Check Corefile configuration +docker exec clab-mylab-tailscale cat /etc/coredns/Corefile + +# View generated DNS records +docker exec clab-mylab-tailscale cat /etc/coredns/hosts +``` + +#### Test DNS Resolution from Tailscale Container + +```bash +# Test DNS lookup from inside the container (assuming lab name is "my-lab") +docker exec clab-my-lab-tailscale nslookup router1.my-lab.clab localhost +``` + +#### Common Issues + +**DNS not resolving:** +- Verify MagicDNS is enabled in Tailscale admin console +- Check that split DNS is configured with the correct domain suffix (`.clab`) +- Ensure the nameserver IP matches your Tailscale container's management IP +- Verify CoreDNS is running: `docker exec clab-mylab-tailscale ps aux | grep coredns` + +**Stale DNS records:** +- DNS records are generated after all nodes are deployed +- If you add nodes dynamically, you may need to redeploy the lab or manually update DNS + +**Wrong domain:** +- Check the `dns.domain` setting in your topology file (defaults to `.clab`) +- Verify split DNS in Tailscale uses the same domain suffix +- Remember: if your lab is named "production", the default domain is "production.clab" + +## Operations + +### Checking Status + +View the Tailscale container: + +```bash +docker ps -f name=tailscale +``` + +Check Tailscale connectivity: + +```bash +# View full Tailscale status +docker exec clab-mylab-tailscale tailscale status + +# Check advertised routes +docker exec clab-mylab-tailscale tailscale status --json | jq '.Self.AllowedIPs' + +# View Tailscale logs +docker logs clab-mylab-tailscale +``` + +### Route Management + +After deployment, you need to approve the advertised routes: + +1. Go to [Tailscale admin console](https://login.tailscale.com/admin/machines) +2. Find your lab's machine (hostname: `clab---tailscale`) +3. Click on the machine +4. Approve the advertised routes + +Or use auto-approval with tagged machines in your ACL: + +```json +{ + "autoApprovers": { + "routes": { + "10.0.0.0/8": ["tag:lab"], + "172.20.0.0/16": ["tag:lab"] + } + } +} +``` + +### Troubleshooting + +#### Routes Not Appearing + +**Problem:** Tailscale routes don't show up in admin console + +**Solution:** +- Ensure route advertisement is enabled in your auth key settings +- Check Tailscale logs: `docker logs clab-mylab-tailscale` +- Verify the container is running: `docker ps -f name=tailscale` + +#### Can't Connect to Lab Nodes + +**Problem:** Unable to reach lab nodes via Tailscale + +**Solutions:** +1. Verify routes are approved in Tailscale admin console +2. Check your client is connected to Tailscale: `tailscale status` +3. Verify the routes are present on your client: `ip route` (Linux) or `netstat -rn` (macOS) +4. Test connectivity to the Tailscale container first: `ping ` + +#### 1:1 NAT Not Working + +**Problem:** NAT translation not functioning + +**Solutions:** +1. Check iptables rules are installed: + ```bash + docker exec clab-mylab-tailscale iptables -t nat -L PREROUTING -n -v + docker exec clab-mylab-tailscale iptables -t nat -L POSTROUTING -n -v + ``` +2. Verify kernel modules: `lsmod | grep iptable_nat` +3. Check subnet sizes match exactly + +#### Auth Key Expired + +**Problem:** Tailscale container fails to authenticate + +**Solution:** +- Generate a new auth key in Tailscale admin console +- Update your topology file +- Redeploy: `sudo containerlab deploy -t mylab.clab.yml --reconfigure` + +## Security Best Practices + +### Auth Key Management + +1. **Use reusable keys** for frequently deployed/destroyed labs +2. **Set expiration dates** appropriate for your use case +3. **Use tagged keys** with ACL restrictions +4. **Store keys securely**, never commit to version control +5. **Use environment variables** for auth keys in topology files + +### Network Segmentation + +Use Tailscale ACLs to segment access: + +```json +{ + "tagOwners": { + "tag:lab-dev": ["group:developers"], + "tag:lab-prod": ["group:admins"] + }, + "acls": [ + { + "action": "accept", + "src": ["group:developers"], + "dst": ["tag:lab-dev:*"] + }, + { + "action": "accept", + "src": ["group:admins"], + "dst": ["tag:lab-prod:*", "tag:lab-dev:*"] + } + ] +} +``` + +### Monitoring + +Set up [Tailscale logging](https://tailscale.com/kb/1255/network-flow-logs/) to track access to your labs. + +## Lifecycle Management + +The Tailscale container is managed automatically by containerlab: + +| Event | Action | +|-------|--------| +| **Lab Deploy** | Tailscale container is created after the management network | +| **Lab Destroy** | Tailscale container is removed | +| **Lab Redeploy** | Tailscale container is reused if management network persists | +| **Network Recreate** | Tailscale container is recreated | + +The Tailscale container is labeled with `clab-is-infrastructure: true` to distinguish it from regular lab nodes. + +## Limitations + +1. **Docker only** - Tailscale integration currently only supports Docker runtime +2. **IPv4 NAT only** - 1:1 NAT feature only supports IPv4 +3. **Single Tailscale container** - One per lab (uses management network) +4. **Auth key required** - No support for interactive authentication + +## Examples + +See the [network documentation](network.md#tailscale-vpn) for complete examples and integration with other management network features. + +## Reference + +- [Tailscale Documentation](https://tailscale.com/kb/) +- [Tailscale ACLs](https://tailscale.com/kb/1018/acls/) +- [Tailscale Auth Keys](https://tailscale.com/kb/1085/auth-keys/) +- [Subnet Routers](https://tailscale.com/kb/1019/subnets/) diff --git a/mkdocs.yml b/mkdocs.yml index 68237cb7a1..e752d9dbc7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - Host: manual/kinds/host.md - Configuration artifacts: manual/conf-artifacts.md - Network: manual/network.md + - Tailscale VPN: manual/tailscale.md - Packet capture & Wireshark: manual/wireshark.md - VM based routers integration: manual/vrnetlab.md - Clabernetes: diff --git a/runtime/docker/scripts/Corefile.tmpl b/runtime/docker/scripts/Corefile.tmpl new file mode 100644 index 0000000000..1b1defa05d --- /dev/null +++ b/runtime/docker/scripts/Corefile.tmpl @@ -0,0 +1,21 @@ +# CoreDNS configuration for containerlab {{.LabName}} + +# Forward DNS zone +{{.Domain}} { + log . "{remote} - {>id} \"{type} {class} {name} {proto} {size} {>do} {>bufsize}\" {rcode} {>rflags} {rsize} {duration}" + errors + hosts /etc/coredns/hosts {{.Domain}} { + fallthrough + } + forward . /etc/resolv.conf +} + +# Forward all other queries +# TODO: Reverse DNS (PTR) support is not currently configured. +# The hosts plugin can auto-generate PTR records, but requires proper +# zone configuration to be authoritative for in-addr.arpa zones. +. { + log . "{remote} - {>id} \"{type} {class} {name} {proto} {size} {>do} {>bufsize}\" {rcode} {>rflags} {rsize} {duration}" + errors + forward . /etc/resolv.conf +} diff --git a/runtime/docker/scripts/README.md b/runtime/docker/scripts/README.md new file mode 100644 index 0000000000..872ec1aff2 --- /dev/null +++ b/runtime/docker/scripts/README.md @@ -0,0 +1,210 @@ +# ContainerLab Tailscale Scripts + +This directory contains embedded scripts used by ContainerLab's Tailscale integration feature. These scripts are embedded into the ContainerLab binary at compile time using Go's `//go:embed` directive. + +## Scripts Overview + +### 1. `nat-setup.sh` +**Purpose**: Configures iptables rules for 1:1 NAT translation between management and NAT subnets. + +**When Used**: Injected into Tailscale container startup command when `one-to-one-nat` is configured. + +**Template Variables**: +- `{{.MgmtSubnet}}` - Real management subnet (e.g., `172.20.20.0/24`) +- `{{.NatSubnet}}` - NAT subnet advertised to Tailscale (e.g., `172.20.200.0/24`) + +**What It Does**: +1. Starts containerboot in background +2. Waits 2 seconds for Tailscale to initialize +3. Applies DNAT rules (incoming traffic: NAT subnet → management subnet) +4. Applies SNAT rules (outgoing traffic via tailscale0: management → NAT subnet) +5. Adds FORWARD rules to allow traffic between subnets +6. Keeps container running by waiting for containerboot + +**Why Embedded**: Ensures NAT rules persist across container restarts by being part of the container's startup command rather than applied post-deployment. + +--- + +### 2. `dns-proxy.py` +**Purpose**: DNS proxy that rewrites DNS queries and responses for bidirectional NAT translation. + +**When Used**: Started when both `dns.enabled` and `one-to-one-nat` are configured (DNS doctoring). + +**Template Variables**: +- `{{.ListenPort}}` - Port to listen on (default: 53) +- `{{.BackendPort}}` - CoreDNS backend port (default: 5353) +- `{{.MgmtSubnet}}` - Real management subnet +- `{{.NatSubnet}}` - NAT subnet + +**What It Does**: +1. Listens for DNS queries on the specified port +2. Detects if query is from Tailscale (100.x.x.x or fd7a: IP range) +3. **PTR Query Rewriting** (for Tailscale clients): + - Rewrites PTR queries for NAT IPs to real IPs before forwarding + - Example: Query for `254.200.20.172.in-addr.arpa` → `254.20.20.172.in-addr.arpa` +4. Forwards query to CoreDNS backend +5. **A Record Response Rewriting** (for Tailscale clients): + - Rewrites A records from management IPs to NAT IPs in responses + - Example: Response with `172.20.20.11` → `172.20.200.11` +6. For local clients: No rewriting, returns original IPs + +**Bidirectional Translation**: +``` +PTR Queries (Request Rewriting): +Tailscale Client queries 172.20.200.11 + → Proxy rewrites query to 172.20.20.11 + → CoreDNS resolves hostname + → Response returned with hostname (no IP translation needed) + +A Queries (Response Rewriting): +Tailscale Client queries hostname + → Proxy forwards query unchanged + → CoreDNS returns 172.20.20.11 + → Proxy rewrites response to 172.20.200.11 + → Client receives NAT IP +``` + +**Architecture**: +``` +Tailscale Client (100.x.x.x) → DNS Proxy (port 53) → CoreDNS (port 5353) + ↓ PTR query rewrite + ↓ A response rewrite + NAT IP returned/queried + +Local Client → DNS Proxy (port 53) → CoreDNS (port 5353) + ↓ (no rewrite) + Real IP returned/queried +``` + +**Why Python**: Simple UDP socket handling and DNS packet manipulation without external dependencies. + +--- + +### 3. `coredns-install.sh` +**Purpose**: Installs CoreDNS binary and optionally Python3 into the Tailscale container. + +**When Used**: During Tailscale DNS setup when `dns.enabled` is true. + +**Template Variables**: +- `{{.CoreDNSVersion}}` - CoreDNS version to install (default: 1.13.1) +- `{{.NeedsPython}}` - Boolean string ("true"/"false") indicating if Python is needed + +**What It Does**: +1. Installs wget (always required) +2. Installs Python3 (only if NAT is enabled for dns-proxy.py) +3. Downloads CoreDNS release tarball from GitHub +4. Extracts binary to `/usr/local/bin/coredns` +5. Creates `/etc/coredns` directory for configuration + +**Why Embedded**: Single installation script that handles all dependencies based on configuration. + +--- + +### 4. `Corefile.tmpl` +**Purpose**: CoreDNS configuration template. + +**When Used**: Generated during DNS setup and updated when DNS records change. + +**Template Variables**: +- `{{.LabName}}` - Lab name for comments +- `{{.Domain}}` - DNS domain to serve (e.g., `cisco-test.clab`) + +**What It Does**: +Configures CoreDNS with two zones: +1. **Lab domain zone**: Serves lab-specific DNS records from `/etc/coredns/hosts` +2. **Catch-all zone (.)**: Forwards all other queries to system resolver + +**Features**: +- Query logging with detailed format +- Error logging +- Fallthrough from hosts to forwarding +- PTR record forwarding for reverse DNS + +**Port Configuration**: Port is specified via `-dns.port` flag at runtime, not in the Corefile. + +--- + +## How Scripts Are Used + +### Build Time +Scripts are embedded into the ContainerLab binary using Go's `embed` package: + +```go +//go:embed scripts/nat-setup.sh +var natSetupScript string +``` + +### Runtime +1. Script templates are parsed using Go's `text/template` package +2. Variables are substituted based on lab configuration +3. Rendered scripts are executed in or written to the Tailscale container + +## Modifying Scripts + +### Development Workflow +1. Edit script files in this directory +2. Rebuild ContainerLab (`make build`) +3. Scripts are automatically embedded in the new binary +4. Test with a lab deployment + +### Template Syntax +Scripts use Go template syntax for variable substitution: +- `{{.VariableName}}` - Simple variable substitution +- Variables must match struct field names in Go code + + +## Troubleshooting + +### View Script Execution +Scripts output is visible in container logs: +```bash +docker logs clab--tailscale +``` + +### Common Issues + +**NAT rules not applied**: +- Check `nat-setup.sh` template variables are correct +- Verify Tailscale has NET_ADMIN capability +- Check container logs for iptables errors + +**DNS proxy not rewriting**: +- Verify client source IP detection (100.x.x.x for Tailscale) +- Check subnet configuration matches NAT setup +- Review dns-proxy logs for rewrite messages + +**CoreDNS installation fails**: +- Check internet connectivity from container +- Verify CoreDNS version exists on GitHub releases +- Check available disk space + +## Architecture Benefits + +### Single Binary Distribution +- No external script files to manage +- Scripts versioned with code +- Deployment simplified + +### Template-Based Configuration +- Type-safe variable substitution +- Compile-time validation +- Runtime flexibility + +### Maintainability +- Scripts in separate files with proper syntax highlighting +- Easy to test and modify +- Clear separation of concerns + +## Related Documentation + +- Main Tailscale documentation: `docs/manual/tailscale.md` +- Go embed documentation: https://pkg.go.dev/embed +- CoreDNS documentation: https://coredns.io/manual/toc/ +- Tailscale documentation: https://tailscale.com/kb/ + +## Version History + +- **2025-11-10**: Initial refactoring to go:embed + - Extracted ~260 lines of embedded strings to separate files + - Added template-based configuration + - Improved maintainability diff --git a/runtime/docker/scripts/coredns-install.sh b/runtime/docker/scripts/coredns-install.sh new file mode 100644 index 0000000000..aa0a1eaa62 --- /dev/null +++ b/runtime/docker/scripts/coredns-install.sh @@ -0,0 +1,112 @@ +#!/bin/sh +# +# CoreDNS Installation Script for ContainerLab Tailscale integration +# +# This script installs CoreDNS and optionally Python3 (when NAT is enabled) +# into the Tailscale container for DNS resolution services. +# +# NOTE: This is a template file. Variables will be replaced by Go's text/template. +# + +set -e # Exit on any error + +COREDNS_VERSION="{{.CoreDNSVersion}}" +NEEDS_PYTHON="{{.NeedsPython}}" + +# Function to log errors +log_error() { + echo "ERROR: $1" >&2 +} + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Check if CoreDNS is already installed with the correct version +if [ -f /usr/local/bin/coredns ]; then + INSTALLED_VERSION=$(/usr/local/bin/coredns -version 2>/dev/null | grep -oP 'CoreDNS-\K[0-9.]+' || echo "unknown") + if [ "$INSTALLED_VERSION" = "$COREDNS_VERSION" ]; then + echo "CoreDNS $COREDNS_VERSION already installed, skipping download" + + # Still install Python if needed + if [ "$NEEDS_PYTHON" = "true" ]; then + if ! command_exists python3; then + echo "Installing Python3..." + if ! apk add --no-cache python3; then + log_error "Failed to install Python3" + exit 1 + fi + fi + fi + + # Ensure config directory exists + mkdir -p /etc/coredns + exit 0 + fi +fi + +echo "Installing CoreDNS version $COREDNS_VERSION..." + +# Verify apk is available +if ! command_exists apk; then + log_error "apk package manager not found (this script requires Alpine Linux)" + exit 1 +fi + +# Install packages (wget and optionally python3) in one command +if [ "$NEEDS_PYTHON" = "true" ]; then + echo "Installing wget and Python3..." + if ! apk add --no-cache wget python3; then + log_error "Failed to install wget and Python3" + exit 1 + fi +else + echo "Installing wget..." + if ! apk add --no-cache wget; then + log_error "Failed to install wget" + exit 1 + fi +fi + +# Verify wget was installed +if ! command_exists wget; then + log_error "wget installation failed" + exit 1 +fi + +# Download and extract CoreDNS in one pipeline (faster than saving to file) +echo "Downloading CoreDNS binary..." +COREDNS_URL="https://github.com/coredns/coredns/releases/download/v${COREDNS_VERSION}/coredns_${COREDNS_VERSION}_linux_amd64.tgz" + +if ! wget -qO- "$COREDNS_URL" | tar -xzC /usr/local/bin/; then + log_error "Failed to download or extract CoreDNS from $COREDNS_URL" + exit 1 +fi + +# Verify CoreDNS binary was extracted +if [ ! -f /usr/local/bin/coredns ]; then + log_error "CoreDNS binary not found after extraction" + exit 1 +fi + +# Set permissions +if ! chmod +x /usr/local/bin/coredns; then + log_error "Failed to set executable permissions on CoreDNS binary" + exit 1 +fi + +# Create config directory +if ! mkdir -p /etc/coredns; then + log_error "Failed to create /etc/coredns directory" + exit 1 +fi + +# Verify CoreDNS can execute +if ! /usr/local/bin/coredns -version >/dev/null 2>&1; then + log_error "CoreDNS binary is not executable or corrupted" + exit 1 +fi + +echo "CoreDNS installation completed successfully" +echo "Installed version: $(/usr/local/bin/coredns -version | head -1)" diff --git a/runtime/docker/scripts/dns-proxy.py b/runtime/docker/scripts/dns-proxy.py new file mode 100644 index 0000000000..efd76769df --- /dev/null +++ b/runtime/docker/scripts/dns-proxy.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +""" +DNS Proxy for ContainerLab Tailscale integration with 1:1 NAT. +Rewrites DNS queries and responses for bidirectional translation: +- PTR queries: Rewrites NAT IP -> real IP (in query name) +- A record responses: Rewrites real IP -> NAT IP (in response data) + +NOTE: This is a template file. Variables will be replaced by Go's text/template. + The { { . Variable } } syntax is NOT valid Python but will be substituted before execution. +""" + +import socket +import struct +import sys +import select +import subprocess + +# Configuration - these will be replaced by Go template at runtime +LISTEN_PORT = int('{{.ListenPort}}') # Template variable - will be replaced with actual port number +BACKEND_PORT = int('{{.BackendPort}}') # Template variable - will be replaced with backend port +MGMT_SUBNET = '{{.MgmtSubnet}}' # Template variable - real management subnet +NAT_SUBNET = '{{.NatSubnet}}' # Template variable - NAT subnet advertised to Tailscale + +# Parse subnet to get base IP and mask +def parse_subnet(subnet): + ip_str, prefix = subnet.split('/') + ip = struct.unpack('!I', socket.inet_aton(ip_str))[0] + mask = (0xFFFFFFFF << (32 - int(prefix))) & 0xFFFFFFFF + return ip & mask, mask + +MGMT_BASE, MGMT_MASK = parse_subnet(MGMT_SUBNET) +NAT_BASE, NAT_MASK = parse_subnet(NAT_SUBNET) + +def translate_ip_to_nat(ip_str): + """Translate real mgmt IP to NAT IP""" + try: + ip = struct.unpack('!I', socket.inet_aton(ip_str))[0] + if (ip & MGMT_MASK) == MGMT_BASE: + offset = ip - MGMT_BASE + nat_ip = NAT_BASE + offset + return socket.inet_ntoa(struct.pack('!I', nat_ip)) + except (OSError, ValueError, struct.error) as e: + # OSError: invalid IP address format + # ValueError: invalid IP string + # struct.error: pack/unpack errors + pass + return ip_str + +def translate_ip_to_real(ip_str): + """Translate NAT IP to real mgmt IP""" + try: + ip = struct.unpack('!I', socket.inet_aton(ip_str))[0] + if (ip & NAT_MASK) == NAT_BASE: + offset = ip - NAT_BASE + real_ip = MGMT_BASE + offset + return socket.inet_ntoa(struct.pack('!I', real_ip)) + except (OSError, ValueError, struct.error) as e: + # OSError: invalid IP address format + # ValueError: invalid IP string + # struct.error: pack/unpack errors + pass + return ip_str + +def parse_ptr_query(name_bytes, start_pos): + """ + Parse PTR query name and extract IP address. + Returns (ip_address, end_pos) or (None, end_pos) if not a valid PTR query. + PTR format: .in-addr.arpa + Example: 254.200.20.172.in-addr.arpa for 172.20.200.254 + """ + labels = [] + pos = start_pos + + while pos < len(name_bytes): + length = name_bytes[pos] + if length == 0: + pos += 1 + break + if length >= 0xC0: # Compression pointer + pos += 2 + break + pos += 1 + label = name_bytes[pos:pos+length].decode('ascii', errors='ignore') + labels.append(label) + pos += length + + # Check if this is a PTR query (ends with in-addr.arpa) + if len(labels) >= 6 and labels[-2:] == ['in-addr', 'arpa']: + # Extract IP octets (reversed) + octets = labels[-6:-2] + if len(octets) == 4 and all(o.isdigit() and 0 <= int(o) <= 255 for o in octets): + # Reverse to get actual IP + ip_str = '.'.join(reversed(octets)) + return ip_str, pos + + return None, pos + +def encode_dns_name(name): + """Encode domain name into DNS wire format""" + parts = name.split('.') + result = bytearray() + for part in parts: + if part: + result.append(len(part)) + result.extend(part.encode('ascii')) + result.append(0) # Null terminator + return bytes(result) + +def is_from_tailscale(addr): + """Check if query is from Tailscale (100.x.x.x or fd7a:)""" + ip = addr[0] + # Tailscale IPv4 uses 100.x.x.x range + if ip.startswith('100.'): + return True + # Tailscale IPv6 uses fd7a: prefix + if ip.startswith('fd7a:'): + return True + return False + +def rewrite_ptr_query(data, from_tailscale): + """Rewrite PTR query to translate NAT IP to real IP in query name""" + if not from_tailscale or len(data) < 12: + return data + + try: + # Parse DNS header + query_id, flags, qdcount, ancount, nscount, arcount = struct.unpack('!HHHHHH', data[:12]) + + # Only process queries (not responses) + if flags & 0x8000: # QR bit set = response + return data + + if qdcount == 0: + return data + + # Parse first question to check if it's a PTR query + ptr_ip, end_pos = parse_ptr_query(data, 12) + + if ptr_ip: + # Translate NAT IP to real IP + real_ip = translate_ip_to_real(ptr_ip) + if real_ip != ptr_ip: + # Reconstruct query with real IP + # PTR format: reverse octets + .in-addr.arpa + real_octets = real_ip.split('.') + ptr_name = '.'.join(reversed(real_octets)) + '.in-addr.arpa' + + # Build new query + new_query = bytearray(data[:12]) # Keep header + new_query.extend(encode_dns_name(ptr_name)) # New PTR name + new_query.extend(data[end_pos:]) # Keep type, class, and rest + + print(f"Rewrote PTR query: {ptr_ip} -> {real_ip}", file=sys.stderr, flush=True) + return bytes(new_query) + + return data + except Exception as e: + print(f"Error rewriting PTR query: {e}", file=sys.stderr, flush=True) + return data + +def rewrite_dns_response(data, from_tailscale): + """Rewrite IP addresses in DNS response if needed""" + if not from_tailscale or len(data) < 12: + return data + + try: + # Parse DNS header + query_id, flags, qdcount, ancount, nscount, arcount = struct.unpack('!HHHHHH', data[:12]) + + # Only process responses with answers + if ancount == 0: + return data + + response = bytearray(data) + pos = 12 + + # Skip questions + for _ in range(qdcount): + while pos < len(response): + length = response[pos] + if length == 0: + pos += 5 # null + type + class + break + if length >= 0xC0: # Compression pointer + pos += 6 # pointer + type + class + break + pos += length + 1 + + # Process answers + for _ in range(ancount): + if pos >= len(response): + break + + # Skip name (handle compression) + if response[pos] >= 0xC0: + pos += 2 + else: + while pos < len(response) and response[pos] != 0: + pos += response[pos] + 1 + pos += 1 + + if pos + 10 > len(response): + break + + rtype, rclass, ttl, rdlength = struct.unpack('!HHIH', response[pos:pos+10]) + pos += 10 + + # Rewrite A records (type 1, IPv4) + if rtype == 1 and rdlength == 4: + ip_bytes = response[pos:pos+4] + ip_str = socket.inet_ntoa(bytes(ip_bytes)) + new_ip_str = translate_ip_to_nat(ip_str) + if new_ip_str != ip_str: + new_ip_bytes = socket.inet_aton(new_ip_str) + response[pos:pos+4] = new_ip_bytes + print(f"Rewrote {ip_str} -> {new_ip_str}", file=sys.stderr, flush=True) + + pos += rdlength + + return bytes(response) + except Exception as e: + print(f"Error rewriting DNS response: {e}", file=sys.stderr, flush=True) + return data + +def main(): + print(f"Starting DNS proxy on port {LISTEN_PORT}, forwarding to 127.0.0.1:{BACKEND_PORT}", flush=True) + print(f"Mgmt subnet: {MGMT_SUBNET}, NAT subnet: {NAT_SUBNET}", flush=True) + + # Get container's management IP address + try: + # Get the container's IP on eth0 (management interface) + result = subprocess.run(['hostname', '-i'], capture_output=True, text=True) + container_ip = result.stdout.strip().split()[0] # First IP + print(f"Binding to {container_ip}:{LISTEN_PORT}", flush=True) + bind_addr = container_ip + except (subprocess.CalledProcessError, IndexError, OSError) as e: + # subprocess.CalledProcessError: hostname command failed + # IndexError: no IP addresses returned + # OSError: subprocess execution error + print(f"Could not determine container IP, binding to 0.0.0.0", flush=True) + bind_addr = '0.0.0.0' + + # Create UDP socket for listening + listen_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + listen_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listen_sock.bind((bind_addr, LISTEN_PORT)) + + # Create UDP socket for backend + backend_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # Map to track queries: backend_sock -> (client_addr, from_tailscale) + pending_queries = {} + + print("DNS proxy ready", flush=True) + + while True: + readable, _, _ = select.select([listen_sock, backend_sock], [], [], 1.0) + + for sock in readable: + if sock == listen_sock: + # Query from client + data, client_addr = listen_sock.recvfrom(512) + from_ts = is_from_tailscale(client_addr) + + # Rewrite PTR queries if from Tailscale (NAT IP -> real IP) + if from_ts: + data = rewrite_ptr_query(data, from_ts) + + # Forward to backend + backend_sock.sendto(data, ('127.0.0.1', BACKEND_PORT)) + + # Store client info for response + query_id = struct.unpack('!H', data[:2])[0] + pending_queries[query_id] = (client_addr, from_ts) + + elif sock == backend_sock: + # Response from backend + data, _ = backend_sock.recvfrom(512) + + query_id = struct.unpack('!H', data[:2])[0] + if query_id in pending_queries: + client_addr, from_ts = pending_queries.pop(query_id) + + # Rewrite A record IPs if from Tailscale (real IP -> NAT IP) + data = rewrite_dns_response(data, from_ts) + + # Send back to client + listen_sock.sendto(data, client_addr) + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print("DNS proxy stopped", flush=True) + sys.exit(0) diff --git a/runtime/docker/scripts/nat-setup.sh b/runtime/docker/scripts/nat-setup.sh new file mode 100644 index 0000000000..220d0d4a10 --- /dev/null +++ b/runtime/docker/scripts/nat-setup.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# +# NAT Setup Script for ContainerLab Tailscale integration +# Applies iptables NETMAP rules for 1:1 NAT translation +# +# This script is injected into the container startup command to ensure +# NAT rules persist across container restarts. +# +# NOTE: This is a template file. Variables will be replaced by Go's text/template. +# The { { . Variable } } syntax is shell script compatible (within quotes). +# + +# Configuration - these will be replaced by Go template +MGMT_SUBNET="{{.MgmtSubnet}}" +NAT_SUBNET="{{.NatSubnet}}" + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Verify iptables is available +if ! command_exists iptables; then + echo "ERROR: iptables not found in container" >&2 + exit 1 +fi + +echo "Starting containerboot in background..." +/usr/local/bin/containerboot & +CONTAINERBOOT_PID=$! + +# Wait for Tailscale to initialize +echo "Waiting for Tailscale to initialize..." +sleep 2 + +# Verify tailscale0 interface exists +RETRY_COUNT=0 +MAX_RETRIES=10 +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if ip link show tailscale0 >/dev/null 2>&1; then + echo "Tailscale interface ready" + break + fi + RETRY_COUNT=$((RETRY_COUNT + 1)) + echo "Waiting for tailscale0 interface... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 1 +done + +if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo "WARNING: tailscale0 interface not found after $MAX_RETRIES attempts" >&2 + echo "NAT rules may not work correctly" >&2 +fi + +echo "Applying NAT rules..." +echo " Management subnet: $MGMT_SUBNET" +echo " NAT subnet: $NAT_SUBNET" + +# DNAT: Translate incoming traffic to NAT subnet -> real mgmt subnet +if ! iptables -t nat -A PREROUTING -d "$NAT_SUBNET" -j NETMAP --to "$MGMT_SUBNET"; then + echo "ERROR: Failed to apply DNAT rule" >&2 + exit 1 +fi + +# SNAT: Translate outgoing traffic from mgmt subnet -> NAT subnet (only via tailscale0) +if ! iptables -t nat -A POSTROUTING -s "$MGMT_SUBNET" -o tailscale0 -j NETMAP --to "$NAT_SUBNET"; then + echo "ERROR: Failed to apply SNAT rule" >&2 + exit 1 +fi + +# Allow forwarding between subnets +if ! iptables -A FORWARD -s "$MGMT_SUBNET" -d "$NAT_SUBNET" -j ACCEPT; then + echo "ERROR: Failed to apply forward rule (mgmt->nat)" >&2 + exit 1 +fi + +if ! iptables -A FORWARD -s "$NAT_SUBNET" -d "$MGMT_SUBNET" -j ACCEPT; then + echo "ERROR: Failed to apply forward rule (nat->mgmt)" >&2 + exit 1 +fi + +echo "NAT rules applied successfully" + +# List applied rules for debugging +echo "Active NAT rules:" +iptables -t nat -L -n -v | grep -E "NETMAP|Chain" + +# Wait for containerboot (keep container running) +wait $CONTAINERBOOT_PID diff --git a/runtime/docker/tailscale.go b/runtime/docker/tailscale.go new file mode 100644 index 0000000000..9ec4b1298e --- /dev/null +++ b/runtime/docker/tailscale.go @@ -0,0 +1,1054 @@ +package docker + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "net" + "strings" + "text/template" + "time" + + "github.com/charmbracelet/log" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/network" + dockerC "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + clabconstants "github.com/srl-labs/containerlab/constants" + clabtypes "github.com/srl-labs/containerlab/types" + clabutils "github.com/srl-labs/containerlab/utils" +) + +//go:embed scripts/nat-setup.sh +var natSetupScript string + +//go:embed scripts/dns-proxy.py +var dnsProxyScript string + +//go:embed scripts/coredns-install.sh +var coreDNSInstallScript string + +//go:embed scripts/Corefile.tmpl +var corefileTemplate string + +const ( + // Default Tailscale image if not specified in config + defaultTailscaleImage = "tailscale/tailscale:latest" + // Default CoreDNS version if not specified in config + defaultCoreDNSVersion = "1.13.1" +) + +// LabContext provides context needed for Tailscale deployment. +type LabContext struct { + Name string + Prefix string + Owner string + LabDir string + TopoFile string +} + +// DeployTailscale deploys a Tailscale container for VPN access to the management network. +// It accepts the lab context to access lab metadata without storing it in DockerRuntime. +func (d *DockerRuntime) DeployTailscale(ctx context.Context, labCtx *LabContext) error { + + // If Tailscale config is not defined at all, skip + if d.mgmt.Tailscale == nil { + log.Debug("Tailscale is not configured, skipping deployment") + return nil + } + + // If Tailscale section exists but enabled is explicitly set to false, skip + // Otherwise deploy (enabled=true by default when section exists and enabled is not set or set to true) + if d.mgmt.Tailscale.Enabled != nil && *d.mgmt.Tailscale.Enabled == false { + log.Debug("Tailscale is explicitly disabled, skipping deployment") + return nil + } + + log.Info("Deploying Tailscale VPN container for management network") + + // Validate configuration + if err := d.validateTailscaleConfig(); err != nil { + return fmt.Errorf("invalid Tailscale configuration: %w", err) + } + + // Build container name following containerlab naming convention: -- + prefix := labCtx.Prefix + if prefix == "" { + prefix = "clab" + } + name := labCtx.Name + if name == "" { + name = "default" + } + + containerName := fmt.Sprintf("%s-%s-tailscale", prefix, name) + hostname := containerName // Use the full container name as hostname to match containerlab convention + + // Determine which Tailscale image to use + tailscaleImage := d.mgmt.Tailscale.Image + if tailscaleImage == "" { + tailscaleImage = defaultTailscaleImage + } + + // Check if container already exists + existingContainers, err := d.Client.ContainerList(ctx, container.ListOptions{ + All: true, + }) + if err != nil { + return fmt.Errorf("failed to list containers: %w", err) + } + + for _, c := range existingContainers { + for _, name := range c.Names { + if strings.TrimPrefix(name, "/") == containerName { + log.Infof("Tailscale container %s already exists, skipping creation", containerName) + + // Start the container if it's not running + if c.State != "running" { + log.Infof("Starting existing Tailscale container %s", containerName) + if err := d.Client.ContainerStart(ctx, c.ID, container.StartOptions{}); err != nil { + return fmt.Errorf("failed to start Tailscale container: %w", err) + } + } + return nil + } + } + } + + // Pull Tailscale image + log.Infof("Pulling Tailscale image: %s", tailscaleImage) + if err := d.PullImage(ctx, tailscaleImage, clabtypes.PullPolicyIfNotPresent); err != nil { + return fmt.Errorf("failed to pull Tailscale image: %w", err) + } + + // Prepare environment variables + env := d.prepareTailscaleEnv(labCtx) + + // Get IPv4 address for the container + ipv4Address := d.mgmt.Tailscale.IPv4Address + if ipv4Address == "" { + // Default to last IP in the mgmt subnet + ipv4Address, err = d.getLastIPFromSubnet(d.mgmt.IPv4Subnet) + if err != nil { + return fmt.Errorf("failed to determine Tailscale container IPv4: %w", err) + } + } + + // Get IPv6 address for the container + var ipv6Address string + if d.mgmt.Tailscale.IPv6Address != "" { + // Use explicitly configured IPv6 address + ipv6Address = d.mgmt.Tailscale.IPv6Address + } else if d.mgmt.IPv6Subnet != "" { + // Default to last IP in the mgmt IPv6 subnet + ipv6Address, err = d.getLastIPFromSubnet(d.mgmt.IPv6Subnet) + if err != nil { + return fmt.Errorf("failed to determine Tailscale container IPv6: %w", err) + } + } + + // Prepare custom startup command if NAT is configured + // If not configured, cmd will be nil and Docker will use the image's default CMD + var cmd []string + if d.mgmt.Tailscale.OneToOneNAT != "" { + cmd = d.prepareStartupCmdWithNAT() + } + + // Create container configuration + containerConfig := &container.Config{ + Image: tailscaleImage, + Hostname: hostname, + Env: env, + Cmd: cmd, + Labels: map[string]string{ + clabconstants.Containerlab: name, + clabconstants.NodeName: "tailscale", + clabconstants.LongName: containerName, + clabconstants.NodeGroup: "", + clabconstants.NodeKind: "tailscale", + clabconstants.Owner: labCtx.Owner, + clabconstants.NodeMgmtNetBr: d.mgmt.Bridge, + clabconstants.NodeLabDir: labCtx.LabDir, + clabconstants.TopoFile: labCtx.TopoFile, + clabconstants.IsInfrastructure: "true", + "containerlab.tailscale": "true", + "containerlab.mgmt-network": d.mgmt.Network, + }, + } + + // Configure healthcheck - use custom if provided, otherwise use defaults + if d.mgmt.Tailscale.Healthcheck != nil { + containerConfig.Healthcheck = &container.HealthConfig{ + Test: d.mgmt.Tailscale.Healthcheck.Test, + Interval: d.mgmt.Tailscale.Healthcheck.GetIntervalDuration(), + Timeout: d.mgmt.Tailscale.Healthcheck.GetTimeoutDuration(), + StartPeriod: d.mgmt.Tailscale.Healthcheck.GetStartPeriodDuration(), + Retries: d.mgmt.Tailscale.Healthcheck.Retries, + } + } else { + // Default healthcheck: verify Tailscale is running + containerConfig.Healthcheck = &container.HealthConfig{ + Test: []string{"CMD-SHELL", "tailscale status --json | grep -q '\"BackendState\": \"Running\"'"}, + Interval: 30 * time.Second, + Timeout: 10 * time.Second, + StartPeriod: 60 * time.Second, + Retries: 3, + } + } + + hostConfig := &container.HostConfig{ + CapAdd: []string{"NET_ADMIN", "NET_RAW"}, + NetworkMode: container.NetworkMode(d.mgmt.Network), + RestartPolicy: container.RestartPolicy{ + Name: "unless-stopped", + }, + Sysctls: map[string]string{ + "net.ipv4.ip_forward": "1", + "net.ipv6.conf.all.forwarding": "1", + }, + } + + networkConfig := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + d.mgmt.Network: { + IPAMConfig: &network.EndpointIPAMConfig{ + IPv4Address: ipv4Address, + IPv6Address: ipv6Address, + }, + }, + }, + } + + // Create the container + logMsg := fmt.Sprintf("Creating Tailscale container %s with IPv4 %s", containerName, ipv4Address) + if ipv6Address != "" { + logMsg += fmt.Sprintf(" and IPv6 %s", ipv6Address) + } + log.Info(logMsg) + resp, err := d.Client.ContainerCreate(ctx, containerConfig, hostConfig, networkConfig, nil, containerName) + if err != nil { + return fmt.Errorf("failed to create Tailscale container: %w", err) + } + + // Start the container + log.Infof("Starting Tailscale container %s", containerName) + if err := d.Client.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + return fmt.Errorf("failed to start Tailscale container: %w", err) + } + + log.Info("Tailscale VPN container deployed successfully") + + // Install and configure CoreDNS if DNS is enabled + if d.mgmt.Tailscale.DNS != nil && d.mgmt.Tailscale.DNS.Enabled != nil && *d.mgmt.Tailscale.DNS.Enabled { + // Wait for Tailscale to be ready before setting up DNS + if err := d.waitForTailscaleReady(ctx, resp.ID, 30*time.Second); err != nil { + log.Warnf("Tailscale readiness check failed: %v, attempting DNS setup anyway", err) + } + if err := d.setupTailscaleDNS(ctx, resp.ID, labCtx); err != nil { + return fmt.Errorf("failed to setup DNS in Tailscale container: %w", err) + } + } + + return nil +} + +// setupTailscaleDNS installs and configures CoreDNS in the Tailscale container. +func (d *DockerRuntime) setupTailscaleDNS(ctx context.Context, containerID string, labCtx *LabContext) error { + log.Info("Setting up CoreDNS in Tailscale container") + + // Determine CoreDNS version to use + coreDNSVersion := defaultCoreDNSVersion + if d.mgmt.Tailscale.DNS != nil && d.mgmt.Tailscale.DNS.CoreDNSVersion != "" { + coreDNSVersion = d.mgmt.Tailscale.DNS.CoreDNSVersion + } + + log.Infof("Installing CoreDNS version %s", coreDNSVersion) + + // Determine if we need Python (only when NAT + DNS are both enabled) + needsPython := d.mgmt.Tailscale.OneToOneNAT != "" + + // Generate installation script from template + installData := struct { + CoreDNSVersion string + NeedsPython string + }{ + CoreDNSVersion: coreDNSVersion, + NeedsPython: fmt.Sprintf("%t", needsPython), + } + + tmpl, err := template.New("coredns-install").Parse(coreDNSInstallScript) + if err != nil { + return fmt.Errorf("failed to parse CoreDNS install template: %w", err) + } + + var installBuf bytes.Buffer + if err := tmpl.Execute(&installBuf, installData); err != nil { + return fmt.Errorf("failed to execute CoreDNS install template: %w", err) + } + + // Execute the installation script + log.Debug("Executing CoreDNS installation script") + execConfig := container.ExecOptions{ + Cmd: []string{"sh", "-c", installBuf.String()}, + AttachStdout: true, + AttachStderr: true, + } + + execID, err := d.Client.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + return fmt.Errorf("failed to create exec for CoreDNS installation: %w", err) + } + + attachResp, err := d.Client.ContainerExecAttach(ctx, execID.ID, container.ExecStartOptions{}) + if err != nil { + return fmt.Errorf("failed to attach to CoreDNS installation: %w", err) + } + defer attachResp.Close() + + // Read output in background (this waits for command to complete) + var outBuf, errBuf bytes.Buffer + outputDone := make(chan error) + + go func() { + _, err := stdcopy.StdCopy(&outBuf, &errBuf, attachResp.Reader) + outputDone <- err + }() + + // Wait for command to complete + select { + case err := <-outputDone: + if err != nil { + log.Warnf("Error reading installation output: %v", err) + } + case <-ctx.Done(): + return ctx.Err() + } + + // Check if installation was successful + inspectResp, err := d.Client.ContainerExecInspect(ctx, execID.ID) + if err != nil { + return fmt.Errorf("failed to inspect CoreDNS installation: %w", err) + } + if inspectResp.ExitCode != 0 { + return fmt.Errorf("CoreDNS installation failed with exit code %d: %s", inspectResp.ExitCode, errBuf.String()) + } + + log.Info("CoreDNS installation completed successfully") + + // Create initial empty Corefile + initialCorefile := d.generateCorefile(labCtx.Name, []DNSRecord{}) + if err := d.writeFileToContainer(ctx, containerID, "/etc/coredns/Corefile", initialCorefile); err != nil { + return fmt.Errorf("failed to write initial Corefile: %w", err) + } + + // Create empty hosts file + initialHosts := d.generateHostsFile(labCtx.Name, []DNSRecord{}) + if err := d.writeFileToContainer(ctx, containerID, "/etc/coredns/hosts", initialHosts); err != nil { + return fmt.Errorf("failed to write initial hosts file: %w", err) + } + + // Determine ports and setup DNS proxy if NAT is enabled + coreDNSPort := 5353 // Internal port when using DNS proxy + publicDNSPort := 53 + if d.mgmt.Tailscale.DNS != nil && d.mgmt.Tailscale.DNS.Port > 0 { + publicDNSPort = d.mgmt.Tailscale.DNS.Port + } + + useProxy := d.mgmt.Tailscale.OneToOneNAT != "" + + // If NAT is enabled, create and start DNS proxy + if useProxy { + log.Info("NAT is enabled, setting up DNS proxy for IP address rewriting") + + // Create the DNS proxy script + proxyScript := d.generateDNSProxyScript(publicDNSPort, coreDNSPort) + if err := d.writeFileToContainer(ctx, containerID, "/usr/local/bin/dns-proxy.py", proxyScript); err != nil { + return fmt.Errorf("failed to write DNS proxy script: %w", err) + } + } else { + // No NAT, CoreDNS listens on public port directly + coreDNSPort = publicDNSPort + } + + // Start CoreDNS in background on internal or public port + // redirecting output to PID 1's stdout for docker logs visibility + // Use awk to prepend timestamp and "coredns:" prefix + startCmd := fmt.Sprintf("nohup sh -c '/usr/local/bin/coredns -conf /etc/coredns/Corefile -dns.port=%d 2>&1 | awk \"{print strftime(\\\"%%Y/%%m/%%d %%H:%%M:%%S\\\"), \\\"coredns:\\\", \\$0; fflush()}\"' >/proc/1/fd/1 2>&1 /proc/1/fd/1 2>&1 0 { + routes := strings.Join(routesToAdvertise, ",") + extraArgs = append(extraArgs, fmt.Sprintf("--advertise-routes=%s", routes)) + } + + // Add tags if specified (tags must be defined in Tailscale ACL) + if len(d.mgmt.Tailscale.Tags) > 0 { + // Prefix each tag with "tag:" if not already present + taggedTags := make([]string, len(d.mgmt.Tailscale.Tags)) + for i, tag := range d.mgmt.Tailscale.Tags { + if strings.HasPrefix(tag, "tag:") { + taggedTags[i] = tag + } else { + taggedTags[i] = "tag:" + tag + } + } + tagStr := strings.Join(taggedTags, ",") + extraArgs = append(extraArgs, fmt.Sprintf("--advertise-tags=%s", tagStr)) + } + + // SNAT - Tailscale enables SNAT by default for advertised routes + if d.mgmt.Tailscale.SNAT != nil && *d.mgmt.Tailscale.SNAT == false { + extraArgs = append(extraArgs, "--snat-subnet-routes=false") + } + + // Accept Routes from Tailnet - default is false + if d.mgmt.Tailscale.AcceptRoutes != nil && *d.mgmt.Tailscale.AcceptRoutes == true { + extraArgs = append(extraArgs, "--accept-routes") + } else { + extraArgs = append(extraArgs, "--accept-routes=false") + } + + // Accept DNS from Tailnet - default is false + if d.mgmt.Tailscale.AcceptDNS != nil && *d.mgmt.Tailscale.AcceptDNS == true { + extraArgs = append(extraArgs, "--accept-dns") + } else { + extraArgs = append(extraArgs, "--accept-dns=false") + } + + // Combine extra args if any + if len(extraArgs) > 0 { + env = append(env, fmt.Sprintf("TS_EXTRA_ARGS=%s", strings.Join(extraArgs, " "))) + } + + return env +} + +// getLastIPFromSubnet returns the last usable IP address in a subnet. +func (d *DockerRuntime) getLastIPFromSubnet(subnet string) (string, error) { + if subnet == "" || subnet == "auto" { + return "", fmt.Errorf("subnet is not specified") + } + + _, ipnet, err := net.ParseCIDR(subnet) + if err != nil { + return "", err + } + + lastHostIP := clabutils.LastHostIPInSubnet(ipnet) + if lastHostIP == nil { + return "", fmt.Errorf("could not determine last host IP in subnet %s", subnet) + } + + return lastHostIP.String(), nil +} + +// prepareStartupCmdWithNAT prepares a startup command that injects iptables NAT rules +// after Tailscale initializes. This ensures NAT rules persist across container restarts. +func (d *DockerRuntime) prepareStartupCmdWithNAT() []string { + // Parse subnets for template + _, mgmtNet, err := net.ParseCIDR(d.mgmt.IPv4Subnet) + if err != nil { + log.Warnf("Failed to parse mgmt subnet for NAT startup script: %v", err) + return nil + } + + _, natNet, err := net.ParseCIDR(d.mgmt.Tailscale.OneToOneNAT) + if err != nil { + log.Warnf("Failed to parse NAT subnet for NAT startup script: %v", err) + return nil + } + + // Prepare template data + data := struct { + MgmtSubnet string + NatSubnet string + }{ + MgmtSubnet: mgmtNet.String(), + NatSubnet: natNet.String(), + } + + // Execute template + tmpl, err := template.New("nat-setup").Parse(natSetupScript) + if err != nil { + log.Warnf("Failed to parse NAT setup template: %v", err) + return nil + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + log.Warnf("Failed to execute NAT setup template: %v", err) + return nil + } + + // Return command that executes the rendered script + return []string{"sh", "-c", buf.String()} +} + +// generateDNSProxyScript creates a Python DNS proxy script that rewrites IP addresses +// in DNS responses based on whether the query came from Tailscale or local network. +func (d *DockerRuntime) generateDNSProxyScript(listenPort, backendPort int) string { + // Parse subnets for validation + _, _, err := net.ParseCIDR(d.mgmt.IPv4Subnet) + if err != nil { + log.Warnf("Failed to parse mgmt subnet for DNS proxy: %v", err) + return "" + } + + _, _, err = net.ParseCIDR(d.mgmt.Tailscale.OneToOneNAT) + if err != nil { + log.Warnf("Failed to parse NAT subnet for DNS proxy: %v", err) + return "" + } + + // Prepare template data + data := struct { + ListenPort int + BackendPort int + MgmtSubnet string + NatSubnet string + }{ + ListenPort: listenPort, + BackendPort: backendPort, + MgmtSubnet: d.mgmt.IPv4Subnet, + NatSubnet: d.mgmt.Tailscale.OneToOneNAT, + } + + // Execute template + tmpl, err := template.New("dns-proxy").Parse(dnsProxyScript) + if err != nil { + log.Warnf("Failed to parse DNS proxy template: %v", err) + return "" + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + log.Warnf("Failed to execute DNS proxy template: %v", err) + return "" + } + + return buf.String() +} + +// UpdateTailscaleDNS updates the DNS records in the Tailscale container if DNS is enabled. +// This should be called after nodes are deployed to populate DNS with node records. +func (d *DockerRuntime) UpdateTailscaleDNS(ctx context.Context, labName string, nodes map[string]interface{}) error { + // Check if Tailscale is configured and DNS is enabled + if d.mgmt.Tailscale == nil { + return nil + } + + if d.mgmt.Tailscale.DNS == nil || d.mgmt.Tailscale.DNS.Enabled == nil || !*d.mgmt.Tailscale.DNS.Enabled { + log.Debug("Tailscale DNS is not enabled, skipping DNS record updates") + return nil + } + + // Find the Tailscale container + filter := filters.NewArgs() + filter.Add("label", fmt.Sprintf("%s=%s", clabconstants.Containerlab, labName)) + filter.Add("label", fmt.Sprintf("%s=%s", clabconstants.NodeKind, "tailscale")) + filter.Add("label", fmt.Sprintf("%s=%s", clabconstants.IsInfrastructure, "true")) + + containers, err := d.Client.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filter, + }) + if err != nil { + return fmt.Errorf("failed to list containers: %w", err) + } + + if len(containers) == 0 { + log.Debug("Tailscale container not found, skipping DNS updates") + return nil + } + + containerID := containers[0].ID + + // Generate DNS records from nodes + dnsRecords := d.generateDNSRecords(labName, nodes) + if len(dnsRecords) == 0 { + log.Debug("No DNS records to update") + return nil + } + + // Generate CoreDNS configuration + corefile := d.generateCorefile(labName, dnsRecords) + + // Write Corefile to container + if err := d.writeFileToContainer(ctx, containerID, "/etc/coredns/Corefile", corefile); err != nil { + return fmt.Errorf("failed to write Corefile: %w", err) + } + + // Write hosts file to container + hostsContent := d.generateHostsFile(labName, dnsRecords) + if err := d.writeFileToContainer(ctx, containerID, "/etc/coredns/hosts", hostsContent); err != nil { + return fmt.Errorf("failed to write hosts file: %w", err) + } + + // TODO: PTR (reverse DNS) support removed temporarily + // CoreDNS hosts plugin can generate PTR records automatically, + // but configuration needs to be fixed to make CoreDNS authoritative + // for reverse zones. See TODO in generateHostsFile() for details. + + // Reload CoreDNS (send SIGUSR1 to coredns process) + if err := d.reloadCoreDNS(ctx, containerID); err != nil { + log.Warnf("Failed to reload CoreDNS: %v", err) + } + + log.Infof("Updated Tailscale DNS with %d node records", len(dnsRecords)) + return nil +} + +// DNSRecord represents a DNS record for a containerlab node. +type DNSRecord struct { + ShortName string + LongName string + IPv4 string + IPv6 string +} + +// generateDNSRecords creates DNS records from the node map. +func (d *DockerRuntime) generateDNSRecords(labName string, nodes map[string]interface{}) []DNSRecord { + var records []DNSRecord + + for _, nodeIface := range nodes { + // Type assert to get node config + // We expect nodes to have MgmtIPv4Address, MgmtIPv6Address, ShortName, LongName + nodeConfig, ok := nodeIface.(interface { + Config() *clabtypes.NodeConfig + }) + if !ok { + continue + } + + cfg := nodeConfig.Config() + if cfg == nil { + continue + } + + // Skip if node has no management IPs + if cfg.MgmtIPv4Address == "" && cfg.MgmtIPv6Address == "" { + continue + } + + record := DNSRecord{ + ShortName: cfg.ShortName, + LongName: cfg.LongName, + IPv4: cfg.MgmtIPv4Address, + IPv6: cfg.MgmtIPv6Address, + } + + records = append(records, record) + } + + return records +} + +// generateCorefile creates a CoreDNS Corefile configuration. +func (d *DockerRuntime) generateCorefile(labName string, records []DNSRecord) string { + domain := fmt.Sprintf("%s.clab", labName) + if d.mgmt.Tailscale.DNS != nil && d.mgmt.Tailscale.DNS.Domain != "" { + domain = d.mgmt.Tailscale.DNS.Domain + } + + // Prepare template data + data := struct { + LabName string + Domain string + }{ + LabName: labName, + Domain: domain, + } + + // Execute template + tmpl, err := template.New("corefile").Parse(corefileTemplate) + if err != nil { + log.Warnf("Failed to parse Corefile template: %v", err) + return "" + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + log.Warnf("Failed to execute Corefile template: %v", err) + return "" + } + + return buf.String() +} + +// generateHostsFile creates a simple hosts file for CoreDNS. +// TODO: PTR (reverse DNS) records are not currently supported. +// CoreDNS hosts plugin can auto-generate PTR records, but proper configuration +// is needed to make it authoritative for reverse zones. This requires further +// investigation and testing with the hosts plugin's reverse DNS capabilities. +func (d *DockerRuntime) generateHostsFile(labName string, records []DNSRecord) string { + domain := fmt.Sprintf("%s.clab", labName) + if d.mgmt.Tailscale.DNS != nil && d.mgmt.Tailscale.DNS.Domain != "" { + domain = d.mgmt.Tailscale.DNS.Domain + } + + var hostsContent strings.Builder + hostsContent.WriteString("# Containerlab DNS records\n") + hostsContent.WriteString("# Generated automatically - do not edit\n\n") + + for _, record := range records { + // Create FQDN + fqdn := fmt.Sprintf("%s.%s", record.ShortName, domain) + + // Add IPv4 record + if record.IPv4 != "" { + hostsContent.WriteString(fmt.Sprintf("%s %s\n", record.IPv4, fqdn)) + } + + // Add IPv6 record + if record.IPv6 != "" { + hostsContent.WriteString(fmt.Sprintf("%s %s\n", record.IPv6, fqdn)) + } + } + + return hostsContent.String() +} + +// writeFileToContainer writes content to a file inside a container. +func (d *DockerRuntime) writeFileToContainer(ctx context.Context, containerID, path, content string) error { + // Create directory if needed + dirCmd := []string{"sh", "-c", fmt.Sprintf("mkdir -p $(dirname %s)", path)} + execConfig := container.ExecOptions{ + Cmd: dirCmd, + AttachStdout: true, + AttachStderr: true, + } + + execID, err := d.Client.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + return fmt.Errorf("failed to create exec for mkdir: %w", err) + } + + if err := d.Client.ContainerExecStart(ctx, execID.ID, container.ExecStartOptions{}); err != nil { + return fmt.Errorf("failed to execute mkdir: %w", err) + } + + // Write file content + writeCmd := []string{"sh", "-c", fmt.Sprintf("cat > %s", path)} + execConfig = container.ExecOptions{ + Cmd: writeCmd, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + } + + execID, err = d.Client.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + return fmt.Errorf("failed to create exec for write: %w", err) + } + + // Attach to exec (this implicitly starts the exec) + attachResp, err := d.Client.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{}) + if err != nil { + return fmt.Errorf("failed to attach to exec: %w", err) + } + defer attachResp.Close() + + // Write content to stdin + if _, err := attachResp.Conn.Write([]byte(content)); err != nil { + return fmt.Errorf("failed to write content: %w", err) + } + attachResp.CloseWrite() + + // Wait for exec to complete + inspectResp, err := d.Client.ContainerExecInspect(ctx, execID.ID) + if err != nil { + return fmt.Errorf("failed to inspect exec: %w", err) + } + + if inspectResp.ExitCode != 0 { + return fmt.Errorf("write command failed with exit code %d", inspectResp.ExitCode) + } + + return nil +} + +// reloadCoreDNS sends a reload signal to CoreDNS running in the container. +func (d *DockerRuntime) reloadCoreDNS(ctx context.Context, containerID string) error { + // Check if CoreDNS is running first + checkCmd := []string{"pgrep", "coredns"} + execConfig := container.ExecOptions{ + Cmd: checkCmd, + AttachStdout: true, + AttachStderr: true, + } + + execID, err := d.Client.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + return fmt.Errorf("failed to check if CoreDNS is running: %w", err) + } + + if err := d.Client.ContainerExecStart(ctx, execID.ID, container.ExecStartOptions{}); err != nil { + return fmt.Errorf("failed to check if CoreDNS is running: %w", err) + } + + inspectResp, err := d.Client.ContainerExecInspect(ctx, execID.ID) + if err != nil || inspectResp.ExitCode != 0 { + log.Debug("CoreDNS is not running, skipping reload") + return nil + } + + // Send SIGUSR1 to coredns process to reload configuration + reloadCmd := []string{"pkill", "-USR1", "coredns"} + execConfig = container.ExecOptions{ + Cmd: reloadCmd, + AttachStdout: true, + AttachStderr: true, + } + + execID, err = d.Client.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + return fmt.Errorf("failed to create exec for reload: %w", err) + } + + if err := d.Client.ContainerExecStart(ctx, execID.ID, container.ExecStartOptions{}); err != nil { + return fmt.Errorf("failed to execute reload: %w", err) + } + + log.Debug("CoreDNS configuration reloaded") + return nil +} + +// waitForTailscaleReady waits for Tailscale to be ready by checking its status. +func (d *DockerRuntime) waitForTailscaleReady(ctx context.Context, containerID string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + execConfig := container.ExecOptions{ + Cmd: []string{"tailscale", "status", "--json"}, + AttachStdout: true, + AttachStderr: true, + } + + execID, err := d.Client.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + time.Sleep(500 * time.Millisecond) + continue + } + + if err := d.Client.ContainerExecStart(ctx, execID.ID, container.ExecStartOptions{}); err != nil { + time.Sleep(500 * time.Millisecond) + continue + } + + inspectResp, err := d.Client.ContainerExecInspect(ctx, execID.ID) + if err == nil && inspectResp.ExitCode == 0 { + log.Debug("Tailscale is ready") + return nil + } + + time.Sleep(500 * time.Millisecond) + } + + return fmt.Errorf("tailscale did not become ready within %v", timeout) +} + +// waitForCoreDNSReady waits for CoreDNS process to be running. +func (d *DockerRuntime) waitForCoreDNSReady(ctx context.Context, containerID string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + + for time.Now().Before(deadline) { + execConfig := container.ExecOptions{ + Cmd: []string{"pgrep", "coredns"}, + AttachStdout: true, + AttachStderr: true, + } + + execID, err := d.Client.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + time.Sleep(200 * time.Millisecond) + continue + } + + if err := d.Client.ContainerExecStart(ctx, execID.ID, container.ExecStartOptions{}); err != nil { + time.Sleep(200 * time.Millisecond) + continue + } + + inspectResp, err := d.Client.ContainerExecInspect(ctx, execID.ID) + if err == nil && inspectResp.ExitCode == 0 { + log.Debug("CoreDNS is ready") + return nil + } + + time.Sleep(200 * time.Millisecond) + } + + return fmt.Errorf("coredns did not start within %v", timeout) +} diff --git a/types/types.go b/types/types.go index a971ef8708..156683592a 100644 --- a/types/types.go +++ b/types/types.go @@ -60,6 +60,50 @@ type MgmtNet struct { ExternalAccess *bool `json:"external-access,omitempty" yaml:"external-access,omitempty"` DriverOpts map[string]string `json:"driver-opts,omitempty" yaml:"driver-opts,omitempty"` + + // Tailscale VPN configuration + Tailscale *TailscaleConfig `json:"tailscale,omitempty" yaml:"tailscale,omitempty"` +} + +// TailscaleConfig contains Tailscale VPN settings for the management network. +type TailscaleConfig struct { + // Enable Tailscale VPN for management network + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // Tailscale authentication key + AuthKey string `json:"authkey,omitempty" yaml:"authkey,omitempty"` + // Image name for Tailscale container + Image string `json:"image,omitempty" yaml:"image,omitempty"` + // IPv4/IPv6 address for the Tailscale container in the management network + IPv4Address string `json:"ipv4-address,omitempty" yaml:"ipv4-address,omitempty"` + IPv6Address string `json:"ipv6-address,omitempty" yaml:"ipv6-address,omitempty"` + // Tags to apply to the Tailscale node + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + // Enable SNAT from mgmt subnet to tailnet (default: true) + SNAT *bool `json:"snat,omitempty" yaml:"snat,omitempty"` + // Accept Routes from Tailnet (default: false) + AcceptRoutes *bool `json:"accept-routes,omitempty" yaml:"accept-routes,omitempty"` + // Accept DNS settings from Tailnet (default: false) + AcceptDNS *bool `json:"accept-dns,omitempty" yaml:"accept-dns,omitempty"` + // Optional 1:1 NAT mapping - maps mgmt subnet to this subnet in tailnet + OneToOneNAT string `json:"one-to-one-nat,omitempty" yaml:"one-to-one-nat,omitempty"` + // Use ephemeral/in-memory state - device auto-removed from tailnet on container stop (default: false) + EphemeralState *bool `json:"ephemeral-state,omitempty" yaml:"ephemeral-state,omitempty"` + // DNS server configuration for MagicDNS split DNS + DNS *TailscaleDNSConfig `json:"dns,omitempty" yaml:"dns,omitempty"` + // Healthcheck configuration (optional, defaults provided) + Healthcheck *HealthcheckConfig `json:"healthcheck,omitempty" yaml:"healthcheck,omitempty"` +} + +// TailscaleDNSConfig contains DNS server settings for the Tailscale container. +type TailscaleDNSConfig struct { + // Enable DNS server in Tailscale container (default: false) + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // DNS domain suffix for containerlab nodes (default: ".clab") + Domain string `json:"domain,omitempty" yaml:"domain,omitempty"` + // DNS server listen port (default: 53) + Port int `json:"port,omitempty" yaml:"port,omitempty"` + // CoreDNS version to use (default: "1.13.1") + CoreDNSVersion string `json:"coredns-version,omitempty" yaml:"coredns-version,omitempty"` } // Interface compliance. diff --git a/utils/ip.go b/utils/ip.go index c42b27638d..700af17cd0 100644 --- a/utils/ip.go +++ b/utils/ip.go @@ -98,3 +98,44 @@ func GetRoutableAddresses() ([]string, error) { return routableAddrs, nil } + +// LastHostIPInSubnet returns the last host IP address in a subnet. +// For IPv4: +// - /32 (single host): returns the only IP +// - /31 (point-to-point): returns the second IP (no broadcast in RFC 3021) +// - /30 and larger: excludes broadcast address (last - 1) +// For IPv6: the last address is always usable (no broadcast concept) +func LastHostIPInSubnet(ipnet *net.IPNet) net.IP { + ip := make(net.IP, len(ipnet.IP)) + copy(ip, ipnet.IP) + + // Set all host bits to 1 to get the last IP + for i := 0; i < len(ip); i++ { + ip[i] |= ^ipnet.Mask[i] + } + + // For IPv4, handle special cases + if ip.To4() != nil { + ones, _ := ipnet.Mask.Size() + + switch ones { + case 32: + // /32 - single host, return the only IP + return ip + case 31: + // /31 - point-to-point (RFC 3021), no broadcast, return second IP + return ip + default: + // /30 and larger - exclude broadcast address + for i := len(ip) - 1; i >= 0; i-- { + if ip[i] > 0 { + ip[i]-- + break + } + ip[i] = 255 + } + } + } + + return ip +} diff --git a/utils/ip_test.go b/utils/ip_test.go index 5413510346..325cbf1006 100644 --- a/utils/ip_test.go +++ b/utils/ip_test.go @@ -133,3 +133,91 @@ func TestCIDRToDDN(t *testing.T) { }) } } + +func TestLastHostIPInSubnet(t *testing.T) { + tests := []struct { + name string + subnet string + want string + }{ + { + name: "ipv4_slash_24", + subnet: "172.20.20.0/24", + want: "172.20.20.254", + }, + { + name: "ipv4_slash_16", + subnet: "10.0.0.0/16", + want: "10.0.255.254", + }, + { + name: "ipv4_slash_8", + subnet: "192.0.0.0/8", + want: "192.255.255.254", + }, + { + name: "ipv4_slash_30", + subnet: "192.168.1.0/30", + want: "192.168.1.2", + }, + { + name: "ipv4_slash_31", + subnet: "192.168.1.0/31", + want: "192.168.1.1", + }, + { + name: "ipv4_slash_32", + subnet: "192.168.1.1/32", + want: "192.168.1.1", + }, + { + name: "ipv6_slash_64", + subnet: "3fff:172:20:20::/64", + want: "3fff:172:20:20:ffff:ffff:ffff:ffff", + }, + { + name: "ipv6_slash_48", + subnet: "fd00:1234:5678::/48", + want: "fd00:1234:5678:ffff:ffff:ffff:ffff:ffff", + }, + { + name: "ipv6_slash_128", + subnet: "2001:db8::1/128", + want: "2001:db8::1", + }, + { + name: "ipv4_100_64", + subnet: "100.64.0.0/24", + want: "100.64.0.254", + }, + { + name: "ipv4_slash_25", + subnet: "172.20.20.0/25", + want: "172.20.20.126", + }, + { + name: "ipv4_slash_31_different_base", + subnet: "10.0.0.254/31", + want: "10.0.0.255", + }, + { + name: "ipv4_slash_32_different_ip", + subnet: "10.10.10.10/32", + want: "10.10.10.10", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, ipnet, err := net.ParseCIDR(tt.subnet) + if err != nil { + t.Fatalf("failed to parse CIDR %q: %v", tt.subnet, err) + } + + got := LastHostIPInSubnet(ipnet) + if diff := cmp.Diff(tt.want, got.String()); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + }) + } +}