Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .icons/tailscale.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added registry/dy-ma/.images/avatar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions registry/dy-ma/README.md
Original file line number Diff line number Diff line change
@@ -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.
151 changes: 151 additions & 0 deletions registry/dy-ma/modules/tailscale/README.md
Original file line number Diff line number Diff line change
@@ -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"
}
```
14 changes: 14 additions & 0 deletions registry/dy-ma/modules/tailscale/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail

log() { echo "[tailscale-install] $*" >&2; }
has() { command -v "$1" &> /dev/null; }

if has tailscale; then
log "Tailscale already installed ($(tailscale version 2> /dev/null | awk 'NR==1{print $1}')), skipping."
exit 0
fi

log "Installing Tailscale..."
curl -fsSL https://tailscale.com/install.sh | sh
log "Installed: $(tailscale version | head -1)"
104 changes: 104 additions & 0 deletions registry/dy-ma/modules/tailscale/main.test.ts
Original file line number Diff line number Diff line change
@@ -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<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
});

// ── Outputs ───────────────────────────────────────────────────────────────

it("uses explicit hostname", async () => {
const state = await runTerraformApply<TestVariables>(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<TestVariables>(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<TestVariables>(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<TestVariables>(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<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
networking_mode: mode,
});
}
});

it("rejects tags without tag: prefix", async () => {
try {
await runTerraformApply<TestVariables>(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<TestVariables>(import.meta.dir, {
agent_id: "some-agent-id",
tags: '["tag:coder", "tag:staging"]',
});
});
});
Loading