diff --git a/.icons/tailscale.svg b/.icons/tailscale.svg
new file mode 100644
index 000000000..6cc6f2d09
--- /dev/null
+++ b/.icons/tailscale.svg
@@ -0,0 +1 @@
+
diff --git a/registry/dy-ma/.images/avatar.png b/registry/dy-ma/.images/avatar.png
new file mode 100644
index 000000000..4a10d0241
Binary files /dev/null and b/registry/dy-ma/.images/avatar.png differ
diff --git a/registry/dy-ma/README.md b/registry/dy-ma/README.md
new file mode 100644
index 000000000..c6b5106c3
--- /dev/null
+++ b/registry/dy-ma/README.md
@@ -0,0 +1,14 @@
+---
+display_name: "Dylan Mou Ang"
+bio: "First time contributor. Got tired of copy-pasting scripts."
+github: "dy-ma"
+avatar: "./.images/avatar.png"
+linkedin: "https://www.linkedin.com/in/dylan-mou-ang"
+website: "https://www.dyma.dev"
+support_email: "dylanmouang@gmail.com"
+status: "community"
+---
+
+# Dylan Mou Ang
+
+First time contributor. Got tired of copy-pasting scripts.
diff --git a/registry/dy-ma/modules/tailscale/README.md b/registry/dy-ma/modules/tailscale/README.md
new file mode 100644
index 000000000..323c1736f
--- /dev/null
+++ b/registry/dy-ma/modules/tailscale/README.md
@@ -0,0 +1,151 @@
+---
+display_name: Tailscale
+description: Joins the workspace to your Tailscale network using OAuth or a pre-generated auth key.
+icon: ../../../../.icons/tailscale.svg
+verified: false
+tags: [networking, tailscale]
+---
+
+# Tailscale
+
+Installs [Tailscale](https://tailscale.com) and joins the workspace to your tailnet on start. Supports kernel and userspace networking, and works with both Tailscale's hosted service and self-hosted [Headscale](https://headscale.net).
+
+```tf
+module "tailscale" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/dy-ma/tailscale/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+ oauth_client_id = "kFvxxxxxxxxxx"
+ oauth_client_secret = "tskey-client-xxxx"
+}
+```
+
+> Do not hardcode credentials in your template. Pass them via Terraform variables, `TF_VAR_*` environment variables, or your preferred secrets manager.
+>
+> **Creating OAuth credentials:** In the Tailscale admin console go to **Settings → OAuth Clients** and create a client with the `auth_keys` scope and the ACL tags your workspaces will use (e.g. `tag:coder-workspace`).
+
+## Examples
+
+### VM workspace (persistent identity)
+
+For VMs or long-lived containers where you want the node to keep its identity across workspace stop/start:
+
+```tf
+module "tailscale" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/dy-ma/tailscale/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+ oauth_client_id = "kFvxxxxxxxxxx"
+ oauth_client_secret = "tskey-client-xxxx"
+ ephemeral = false
+ networking_mode = "kernel"
+ state_dir = "/var/lib/tailscale"
+}
+```
+
+### Ephemeral pod / unprivileged container
+
+For Kubernetes pods or Docker containers without access to `/dev/net/tun`. Userspace mode exposes a SOCKS5 proxy on port `1080` and an HTTP proxy on port `3128` for outbound tailnet access:
+
+```tf
+module "tailscale" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/dy-ma/tailscale/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+ oauth_client_id = "kFvxxxxxxxxxx"
+ oauth_client_secret = "tskey-client-xxxx"
+ ephemeral = true
+ networking_mode = "userspace"
+ state_dir = "/tmp/tailscale-state"
+}
+```
+
+### Pre-generated auth key
+
+If you prefer to manage key rotation externally, pass an auth key directly and skip the OAuth flow:
+
+```tf
+module "tailscale" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/dy-ma/tailscale/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+ auth_key = "tskey-auth-xxxx"
+}
+```
+
+### Headscale
+
+Point `tailscale_api_url` at your Headscale server and pass a pre-generated auth key:
+
+```tf
+module "tailscale" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/dy-ma/tailscale/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+ auth_key = "tskey-auth-xxxx"
+ tailscale_api_url = "https://headscale.example.com"
+}
+```
+
+### Tailscale SSH
+
+Enable Tailscale SSH so tailnet members can reach workspaces directly without managing keys. The `tags` variable (default `["tag:coder-workspace"]`) controls which ACL tag the node advertises — override it if your policy uses a different tag.
+
+```tf
+module "tailscale" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/dy-ma/tailscale/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+ oauth_client_id = "kFvxxxxxxxxxx"
+ oauth_client_secret = "tskey-client-xxxx"
+ ssh = true
+ tags = ["tag:coder-workspace"] # override if needed
+}
+```
+
+You also need to allow SSH access in your [Tailscale ACL policy](https://login.tailscale.com/admin/acls). At minimum, add an SSH rule and a traffic rule for the tag:
+
+```json
+{
+ "tagOwners": {
+ "tag:coder-workspace": ["autogroup:admin"]
+ },
+ "acls": [
+ {
+ "action": "accept",
+ "src": ["autogroup:member"],
+ "dst": ["tag:coder-workspace:*"]
+ }
+ ],
+ "ssh": [
+ {
+ "action": "check",
+ "src": ["autogroup:member"],
+ "dst": ["tag:coder-workspace"],
+ "users": ["autogroup:nonroot", "root"]
+ }
+ ]
+}
+```
+
+### Extra flags
+
+Pass any additional `tailscale up` flags not covered by dedicated variables:
+
+```tf
+module "tailscale" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/dy-ma/tailscale/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+ oauth_client_id = "kFvxxxxxxxxxx"
+ oauth_client_secret = "tskey-client-xxxx"
+ extra_flags = "--exit-node=100.64.0.1"
+}
+```
diff --git a/registry/dy-ma/modules/tailscale/main.test.ts b/registry/dy-ma/modules/tailscale/main.test.ts
new file mode 100644
index 000000000..f4d32f69d
--- /dev/null
+++ b/registry/dy-ma/modules/tailscale/main.test.ts
@@ -0,0 +1,104 @@
+import { describe, expect, it } from "bun:test";
+import {
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "~test";
+
+describe("tailscale", async () => {
+ type TestVariables = {
+ agent_id: string;
+ auth_key?: string;
+ tailscale_api_url?: string;
+ oauth_client_id?: string;
+ oauth_client_secret?: string;
+ tailnet?: string;
+ hostname?: string;
+ tags?: string;
+ ephemeral?: boolean;
+ preauthorized?: boolean;
+ networking_mode?: string;
+ socks5_proxy_port?: number;
+ http_proxy_port?: number;
+ accept_dns?: boolean;
+ accept_routes?: boolean;
+ advertise_routes?: string;
+ ssh?: boolean;
+ extra_flags?: string;
+ state_dir?: string;
+ };
+
+ await runTerraformInit(import.meta.dir);
+
+ // Only agent_id has no default — all other vars are optional.
+ testRequiredVariables(import.meta.dir, {
+ agent_id: "some-agent-id",
+ });
+
+ // ── Outputs ───────────────────────────────────────────────────────────────
+
+ it("uses explicit hostname", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ hostname: "my-workspace",
+ });
+ expect(state.outputs.hostname.value).toBe("my-workspace");
+ });
+
+ it("defaults state_dir to empty string", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ });
+ expect(state.outputs.state_dir.value).toBe("");
+ });
+
+ it("uses explicit state_dir", async () => {
+ const state = await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ state_dir: "/tmp/tailscale-state",
+ });
+ expect(state.outputs.state_dir.value).toBe("/tmp/tailscale-state");
+ });
+
+ // ── Validation ────────────────────────────────────────────────────────────
+
+ it("rejects invalid networking_mode", async () => {
+ try {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ networking_mode: "invalid",
+ });
+ throw new Error("expected apply to fail");
+ } catch (e) {
+ expect(e).toBeInstanceOf(Error);
+ }
+ });
+
+ it("accepts all valid networking modes", async () => {
+ for (const mode of ["auto", "kernel", "userspace"]) {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ networking_mode: mode,
+ });
+ }
+ });
+
+ it("rejects tags without tag: prefix", async () => {
+ try {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ tags: '["no-prefix"]',
+ });
+ throw new Error("expected apply to fail");
+ } catch (e) {
+ expect(e).toBeInstanceOf(Error);
+ }
+ });
+
+ it("accepts tags with tag: prefix", async () => {
+ await runTerraformApply(import.meta.dir, {
+ agent_id: "some-agent-id",
+ tags: '["tag:coder", "tag:staging"]',
+ });
+ });
+});
diff --git a/registry/dy-ma/modules/tailscale/main.tf b/registry/dy-ma/modules/tailscale/main.tf
new file mode 100644
index 000000000..33df59cf6
--- /dev/null
+++ b/registry/dy-ma/modules/tailscale/main.tf
@@ -0,0 +1,219 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 2.5"
+ }
+ }
+}
+
+data "coder_workspace" "me" {}
+
+locals {
+ icon_url = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tailscale.svg"
+ hostname = var.hostname != "" ? var.hostname : data.coder_workspace.me.name
+ tags_json = jsonencode(var.tags)
+ tags_csv = join(",", var.tags)
+}
+
+variable "agent_id" {
+ type = string
+ description = "The ID of a Coder agent."
+}
+
+variable "auth_key" {
+ type = string
+ sensitive = true
+ default = ""
+ description = <<-EOF
+ A pre-generated Tailscale or Headscale auth key. When set, the OAuth
+ client credentials flow is skipped and this key is passed directly to
+ tailscale up. Use this for Headscale or when you prefer to manage key
+ rotation externally (e.g. via Vault).
+
+ Either auth_key or both oauth_client_id and oauth_client_secret must be
+ provided. If auth_key is set, oauth_client_id and oauth_client_secret are
+ ignored.
+ EOF
+}
+
+variable "tailscale_api_url" {
+ type = string
+ default = "https://api.tailscale.com"
+ description = <<-EOF
+ Base URL of the control server. Defaults to Tailscale's hosted service.
+ Set this to your own server URL (e.g. a Headscale instance).
+ EOF
+}
+
+variable "oauth_client_id" {
+ type = string
+ sensitive = true
+ default = ""
+ description = "Tailscale OAuth client ID with the auth_keys scope."
+}
+
+variable "oauth_client_secret" {
+ type = string
+ sensitive = true
+ default = ""
+ description = "Tailscale OAuth client secret with the auth_keys scope."
+}
+
+variable "tailnet" {
+ type = string
+ default = "-"
+ description = "Tailnet name. Defaults to '-' which resolves to the default tailnet for the Oauth client."
+}
+
+variable "hostname" {
+ type = string
+ default = ""
+ description = "Hostname to register in the tailnet. Leave blank to use the workspace name."
+}
+
+variable "tags" {
+ type = list(string)
+ default = ["tag:coder-workspace"]
+ description = "ACL tags to apply to the node."
+ validation {
+ condition = alltrue([for t in var.tags : startswith(t, "tag:")])
+ error_message = "All tags must start with \"tag:\"."
+ }
+}
+
+variable "ephemeral" {
+ type = bool
+ default = true
+ description = "Whether to register the node as ephemeral."
+}
+
+variable "preauthorized" {
+ type = bool
+ default = true
+ description = "Skip manual device approval when the node joins the tailnet"
+}
+
+variable "networking_mode" {
+ type = string
+ default = "auto"
+ description = <<-EOF
+ Tailscale networking mode.
+
+ auto — detect from environment. Uses kernel networking if
+ /dev/net/tun is accessible, userspace otherwise.
+ kernel — force kernel networking (requires TUN device). Suitable
+ for VMs and privileged containers.
+ userspace — force userspace networking. Required for unprivileged
+ containers. Enables SOCKS5/HTTP proxies for outbound
+ tailnet access.
+ EOF
+ validation {
+ condition = contains(["auto", "kernel", "userspace"], var.networking_mode)
+ error_message = "networking_mode must be one of: auto, kernel, userspace."
+ }
+}
+
+variable "socks5_proxy_port" {
+ type = number
+ default = 1080
+ description = <<-EOF
+ Port for the SOCKS5 proxy exposed by tailscaled in userspace mode.
+ Set to 0 to disable. Only active when networking_mode resolves to userspace.
+ EOF
+}
+
+variable "http_proxy_port" {
+ type = number
+ default = 3128
+ description = <<-EOF
+ Port for the HTTP proxy exposed by tailscaled in userspace mode.
+ Set to 0 to disable. Only active when networking_mode resolves to userspace.
+ EOF
+}
+
+variable "accept_dns" {
+ type = bool
+ default = true
+ description = "Accept DNS configuration from the tailnet (MagicDNS)."
+}
+
+variable "accept_routes" {
+ type = bool
+ default = false
+ description = "Accept subnet routes advertised by other nodes in the tailnet"
+}
+
+variable "advertise_routes" {
+ type = list(string)
+ default = []
+ description = "CIDR ranges this workspace should advertise as subnet routes."
+}
+
+variable "ssh" {
+ type = bool
+ default = false
+ description = "Enable Tailscale SSH. Allows other tailnet nodes to ssh into this workspace as defined by your tailnet policy."
+}
+
+variable "extra_flags" {
+ type = string
+ default = ""
+ description = <<-EOF
+ Additional flags to append to the `tailscale up` command verbatim.
+ Use this for any options not covered by dedicated variables, e.g.
+ `--exit-node=100.x.y.z` or `--shields-up`.
+ EOF
+}
+
+variable "state_dir" {
+ type = string
+ default = ""
+ description = <<-EOF
+ Directory for tailscaled state files. Leave empty to use tailscaled's
+ default location. Override to a persistent path on VMs (e.g.
+ /var/lib/tailscale) or a non-persistent path on ephemeral pods
+ (e.g. /tmp/tailscale-state).
+ EOF
+}
+
+resource "coder_script" "install_tailscale" {
+ agent_id = var.agent_id
+ display_name = "Tailscale"
+ icon = local.icon_url
+ script = templatefile("${path.module}/run.sh", {
+ TAILSCALE_API_URL = var.tailscale_api_url
+ AUTH_KEY = var.auth_key
+ OAUTH_CLIENT_ID = var.oauth_client_id
+ OAUTH_CLIENT_SECRET = var.oauth_client_secret
+ TAILNET = var.tailnet
+ HOSTNAME = local.hostname
+ TAGS_JSON = local.tags_json
+ TAGS_CSV = local.tags_csv
+ EPHEMERAL = var.ephemeral
+ PREAUTHORIZED = var.preauthorized
+ NETWORKING_MODE = var.networking_mode
+ SOCKS5_PORT = var.socks5_proxy_port
+ HTTP_PROXY_PORT = var.http_proxy_port
+ ACCEPT_DNS = var.accept_dns
+ ACCEPT_ROUTES = var.accept_routes
+ ADVERTISE_ROUTES = join(",", var.advertise_routes)
+ SSH = var.ssh
+ EXTRA_FLAGS = var.extra_flags
+ STATE_DIR = var.state_dir
+ })
+ run_on_start = true
+ run_on_stop = false
+}
+
+output "hostname" {
+ description = "Hostname registered in tailnet."
+ value = local.hostname
+}
+
+output "state_dir" {
+ description = "Directory where tailscaled state is persisted. Empty string means tailscaled's default location."
+ value = var.state_dir
+}
\ No newline at end of file
diff --git a/registry/dy-ma/modules/tailscale/run.sh b/registry/dy-ma/modules/tailscale/run.sh
new file mode 100755
index 000000000..d90db7d27
--- /dev/null
+++ b/registry/dy-ma/modules/tailscale/run.sh
@@ -0,0 +1,233 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Values injected by templatefile() in main.tf
+TAILSCALE_API_URL="${TAILSCALE_API_URL}"
+AUTH_KEY="${AUTH_KEY}"
+OAUTH_CLIENT_ID="${OAUTH_CLIENT_ID}"
+OAUTH_CLIENT_SECRET="${OAUTH_CLIENT_SECRET}"
+TAILNET="${TAILNET}"
+TS_HOSTNAME="${HOSTNAME}"
+TAGS_JSON='${TAGS_JSON}'
+TAGS_CSV="${TAGS_CSV}"
+EPHEMERAL="${EPHEMERAL}"
+PREAUTHORIZED="${PREAUTHORIZED}"
+NETWORKING_MODE="${NETWORKING_MODE}"
+SOCKS5_PORT="${SOCKS5_PORT}"
+HTTP_PROXY_PORT="${HTTP_PROXY_PORT}"
+ACCEPT_DNS="${ACCEPT_DNS}"
+ACCEPT_ROUTES="${ACCEPT_ROUTES}"
+ADVERTISE_ROUTES="${ADVERTISE_ROUTES}"
+SSH="${SSH}"
+EXTRA_FLAGS="${EXTRA_FLAGS}"
+STATE_DIR="${STATE_DIR}"
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+log() { echo "[tailscale] $*" >&2; }
+die() {
+ echo "[tailscale] ERROR: $*" >&2
+ exit 1
+}
+has() { command -v "$1" &> /dev/null; }
+
+# ── 1. Install Tailscale ──────────────────────────────────────────────────────
+
+install_tailscale() {
+ if has tailscale; then
+ log "Tailscale already installed ($(tailscale version 2> /dev/null | awk 'NR==1{print $1}')), skipping."
+ return
+ fi
+
+ log "Installing Tailscale..."
+ curl -fsSL https://tailscale.com/install.sh | sh
+ log "Installed: $(tailscale version | head -1)"
+}
+
+# ── 2. Detect networking mode ─────────────────────────────────────────────────
+
+resolve_networking_mode() {
+ if [ "$NETWORKING_MODE" != "auto" ]; then
+ echo "$NETWORKING_MODE"
+ return
+ fi
+ if [ -c /dev/net/tun ] && [ -r /dev/net/tun ] && [ -w /dev/net/tun ]; then
+ echo "kernel"
+ else
+ echo "userspace"
+ fi
+}
+
+# ── 3. Start tailscaled ───────────────────────────────────────────────────────
+
+start_tailscaled() {
+ local mode="$1"
+
+ # Build daemon flags
+ local daemon_flags="--socket=/var/run/tailscale/tailscaled.sock"
+ if [ -n "$STATE_DIR" ]; then
+ mkdir -p "$STATE_DIR"
+ daemon_flags="--state=$STATE_DIR/tailscaled.state $daemon_flags"
+ fi
+ if [ "$mode" = "userspace" ]; then
+ daemon_flags="$daemon_flags --tun=userspace-networking"
+ [ "$SOCKS5_PORT" != "0" ] && daemon_flags="$daemon_flags --socks5-server=localhost:$SOCKS5_PORT"
+ [ "$HTTP_PROXY_PORT" != "0" ] && daemon_flags="$daemon_flags --outbound-http-proxy-listen=localhost:$HTTP_PROXY_PORT"
+ fi
+
+ if has systemctl && systemctl is-system-running --quiet 2> /dev/null; then
+ if [ "$mode" = "userspace" ]; then
+ # Drop-in override so we don't touch the upstream unit file
+ sudo mkdir -p /etc/systemd/system/tailscaled.service.d
+ printf '[Service]\nExecStart=\nExecStart=-/usr/sbin/tailscaled %s\n' \
+ "$daemon_flags" \
+ | sudo tee /etc/systemd/system/tailscaled.service.d/coder.conf > /dev/null
+ sudo systemctl daemon-reload
+ fi
+ sudo systemctl enable --now tailscaled
+ log "tailscaled started via systemd."
+ else
+ if pgrep -x tailscaled &> /dev/null; then
+ log "tailscaled already running."
+ return
+ fi
+ sudo mkdir -p /var/run/tailscale
+ # shellcheck disable=SC2086
+ sudo tailscaled $daemon_flags &> /tmp/tailscaled.log &
+ sleep 2
+ log "tailscaled started in background."
+ fi
+}
+
+# ── 4. Generate a single-use auth key ─────────────────────────────────────────
+# OAuth creds stay on this machine. We exchange them for a short-lived
+# access token, use that to create a 5-minute single-use auth key, then
+# discard both. The auth key is the only thing passed to tailscale up.
+
+generate_auth_key() {
+ has curl || die "curl is required."
+ has jq || die "jq is required."
+
+ log "Fetching Tailscale access token..."
+ local token_response
+ token_response=$(curl -fsSL \
+ -d "client_id=$OAUTH_CLIENT_ID" \
+ -d "client_secret=$OAUTH_CLIENT_SECRET" \
+ "$TAILSCALE_API_URL/api/v2/oauth/token") \
+ || die "Failed to fetch OAuth access token."
+
+ local access_token
+ access_token=$(echo "$token_response" | jq -r '.access_token')
+ [ "$access_token" = "null" ] || [ -z "$access_token" ] \
+ && die "OAuth token response did not contain an access_token. Check your client ID and secret."
+
+ log "Generating single-use auth key..."
+ local key_response http_status
+ key_response=$(curl -sSL -w "\n%%{http_code}" -X POST \
+ -H "Authorization: Bearer $access_token" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"capabilities\": {
+ \"devices\": {
+ \"create\": {
+ \"reusable\": false,
+ \"ephemeral\": $EPHEMERAL,
+ \"preauthorized\": $PREAUTHORIZED,
+ \"tags\": $TAGS_JSON
+ }
+ }
+ },
+ \"expirySeconds\": 300
+ }" \
+ "$TAILSCALE_API_URL/api/v2/tailnet/$TAILNET/keys")
+ http_status=$(echo "$key_response" | tail -1)
+ key_response=$(echo "$key_response" | head -n -1)
+ if [ "$http_status" != "200" ]; then
+ die "Failed to generate auth key (HTTP $http_status): $key_response"
+ fi
+
+ local auth_key
+ auth_key=$(echo "$key_response" | jq -r '.key')
+ [ "$auth_key" = "null" ] || [ -z "$auth_key" ] \
+ && die "Key response did not contain a key. Response: $key_response"
+
+ echo "$auth_key"
+}
+
+# ── 5. Bring up Tailscale ─────────────────────────────────────────────────────
+
+bring_up() {
+ local auth_key="$1"
+ local mode="$2"
+
+ # Assemble tailscale up flags
+ local flags="--hostname=$TS_HOSTNAME"
+ flags="$flags --advertise-tags=$TAGS_CSV"
+ flags="$flags --accept-dns=$ACCEPT_DNS"
+ [ "$TAILSCALE_API_URL" != "https://api.tailscale.com" ] && flags="$flags --login-server=$TAILSCALE_API_URL"
+ [ "$ACCEPT_ROUTES" = "true" ] && flags="$flags --accept-routes"
+ [ -n "$ADVERTISE_ROUTES" ] && flags="$flags --advertise-routes=$ADVERTISE_ROUTES"
+ [ "$SSH" = "true" ] && flags="$flags --ssh"
+ [ "$mode" = "userspace" ] && flags="$flags --netfilter-mode=off"
+ [ -n "$EXTRA_FLAGS" ] && flags="$flags $EXTRA_FLAGS"
+
+ if [ -n "$auth_key" ]; then
+ # shellcheck disable=SC2086
+ sudo tailscale up --auth-key="$auth_key" $flags
+ else
+ # Already authenticated — re-apply flags only, no re-auth
+ # shellcheck disable=SC2086
+ sudo tailscale up $flags
+ fi
+}
+
+# ── 6. Set proxy env vars (userspace only) ────────────────────────────────────
+
+configure_proxy_env() {
+ local mode="$1"
+ [ "$mode" != "userspace" ] && return
+
+ local lines=""
+ [ "$SOCKS5_PORT" != "0" ] \
+ && lines="$lines"$'\n'"export ALL_PROXY=socks5://localhost:$SOCKS5_PORT"
+ [ "$HTTP_PROXY_PORT" != "0" ] \
+ && lines="$lines"$'\n'"export http_proxy=http://localhost:$HTTP_PROXY_PORT"$'\n'"export https_proxy=http://localhost:$HTTP_PROXY_PORT"
+
+ if [ -n "$lines" ]; then
+ printf '# Set by tailscale Coder module%s\n' "$lines" \
+ | sudo tee /etc/profile.d/tailscale-proxy.sh > /dev/null
+ log "Proxy env vars written to /etc/profile.d/tailscale-proxy.sh"
+ fi
+}
+
+# ── Main ──────────────────────────────────────────────────────────────────────
+
+main() {
+ install_tailscale
+
+ local mode
+ mode=$(resolve_networking_mode)
+ log "Networking mode: $mode"
+
+ start_tailscaled "$mode"
+
+ local auth_key=""
+ if [ -n "$AUTH_KEY" ]; then
+ log "Using provided auth key."
+ auth_key="$AUTH_KEY"
+ elif sudo tailscale status --json 2> /dev/null | grep -q '"BackendState":"Running"'; then
+ log "Tailscale already connected. Re-applying flags..."
+ # auth_key stays empty — bring_up will skip --auth-key
+ else
+ log "Not connected. Generating auth key via OAuth..."
+ auth_key=$(generate_auth_key)
+ fi
+
+ bring_up "$auth_key" "$mode"
+ configure_proxy_env "$mode"
+
+ log "Status:"
+ tailscale status
+}
+
+main
diff --git a/registry/dy-ma/modules/tailscale/tailscale.tftest.hcl b/registry/dy-ma/modules/tailscale/tailscale.tftest.hcl
new file mode 100644
index 000000000..e2e40cc22
--- /dev/null
+++ b/registry/dy-ma/modules/tailscale/tailscale.tftest.hcl
@@ -0,0 +1,46 @@
+run "plan_with_required_vars" {
+ command = plan
+
+ variables {
+ agent_id = "example-agent-id"
+ }
+}
+
+run "plan_with_oauth" {
+ command = plan
+
+ variables {
+ agent_id = "example-agent-id"
+ oauth_client_id = "tskey-client-xxxx"
+ oauth_client_secret = "tskey-secret-xxxx"
+ }
+}
+
+run "plan_with_auth_key" {
+ command = plan
+
+ variables {
+ agent_id = "example-agent-id"
+ auth_key = "tskey-auth-xxxx"
+ }
+}
+
+run "plan_userspace_mode" {
+ command = plan
+
+ variables {
+ agent_id = "example-agent-id"
+ auth_key = "tskey-auth-xxxx"
+ networking_mode = "userspace"
+ }
+}
+
+run "plan_with_extra_flags" {
+ command = plan
+
+ variables {
+ agent_id = "example-agent-id"
+ auth_key = "tskey-auth-xxxx"
+ extra_flags = "--shields-up"
+ }
+}