Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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"
}
```
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
Loading