@yawlabs/tailscale-mcp 0.12.9 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -1
- package/dist/index.js +31 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -454,10 +454,47 @@ MCP Resources expose read-only data clients can browse without a tool call.
|
|
|
454
454
|
For the simple "deploy ACL from git on merge" workflow, you don't need an MCP server or an agent — use the built-in CLI:
|
|
455
455
|
|
|
456
456
|
```bash
|
|
457
|
+
# PR check: validate the proposed policy without touching the tailnet
|
|
458
|
+
npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
|
|
459
|
+
|
|
460
|
+
# On merge: ETag fetch + validate + deploy with If-Match, fail-closed at every step
|
|
457
461
|
npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.json
|
|
458
462
|
```
|
|
459
463
|
|
|
460
|
-
|
|
464
|
+
Works in any CI system. Set `TAILSCALE_API_KEY` and `TAILSCALE_TAILNET` as env vars. Both commands exit non-zero on any failure; `deploy-acl` refuses to deploy without an ETag (so a concurrent Admin Console edit can never be silently clobbered) and reports a 412 as a concurrent-edit conflict you resolve by re-running.
|
|
465
|
+
|
|
466
|
+
A complete GitHub Actions workflow — validate on PR, deploy on merge:
|
|
467
|
+
|
|
468
|
+
```yaml
|
|
469
|
+
name: tailscale-acl
|
|
470
|
+
on:
|
|
471
|
+
pull_request:
|
|
472
|
+
paths: ["tailscale/acl.json"]
|
|
473
|
+
push:
|
|
474
|
+
branches: [main]
|
|
475
|
+
paths: ["tailscale/acl.json"]
|
|
476
|
+
|
|
477
|
+
jobs:
|
|
478
|
+
acl:
|
|
479
|
+
runs-on: ubuntu-latest
|
|
480
|
+
env:
|
|
481
|
+
TAILSCALE_API_KEY: ${{ secrets.TAILSCALE_API_KEY }}
|
|
482
|
+
TAILSCALE_TAILNET: your-tailnet.ts.net # or omit: defaults to the key's tailnet
|
|
483
|
+
steps:
|
|
484
|
+
- uses: actions/checkout@v4
|
|
485
|
+
- uses: actions/setup-node@v4
|
|
486
|
+
with: { node-version: 22 }
|
|
487
|
+
- name: Validate ACL
|
|
488
|
+
if: github.event_name == 'pull_request'
|
|
489
|
+
run: npx -y @yawlabs/tailscale-mcp@latest validate-acl tailscale/acl.json
|
|
490
|
+
- name: Deploy ACL
|
|
491
|
+
if: github.event_name == 'push'
|
|
492
|
+
run: npx -y @yawlabs/tailscale-mcp@latest deploy-acl tailscale/acl.json
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
For reproducible deploys, replace `@latest` with a pinned version.
|
|
496
|
+
|
|
497
|
+
> **If you hand-roll this with `curl` instead:** Tailscale's ACL endpoint only returns the `ETag` header on **GET**, not HEAD. A `curl -I` (HEAD) ETag fetch silently yields an empty value — and an empty `If-Match` either deploys unguarded (clobbering concurrent edits) or trips your guard and fails the deploy. Fetch the ETag with a GET (`curl -fsS -D - -o /dev/null ...`), and fail the job if it comes back empty. The CLI above does all of this for you.
|
|
461
498
|
|
|
462
499
|
**Optional:** Lock the Admin Console to prevent manual edits that drift from git. Ask your agent:
|
|
463
500
|
|
package/dist/index.js
CHANGED
|
@@ -31264,19 +31264,15 @@ function parseValidationError(rawBody) {
|
|
|
31264
31264
|
if (typeof obj.error === "string" && obj.error.length > 0) return obj.error;
|
|
31265
31265
|
return void 0;
|
|
31266
31266
|
}
|
|
31267
|
-
|
|
31268
|
-
let policy;
|
|
31267
|
+
function readPolicyFile(filePath) {
|
|
31269
31268
|
try {
|
|
31270
|
-
|
|
31269
|
+
return readFileSync(filePath, "utf-8");
|
|
31271
31270
|
} catch (err) {
|
|
31272
31271
|
console.error(`Failed to read ${filePath}: ${err instanceof Error ? err.message : err}`);
|
|
31273
31272
|
process.exit(1);
|
|
31274
31273
|
}
|
|
31275
|
-
|
|
31276
|
-
|
|
31277
|
-
console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
|
|
31278
|
-
process.exit(1);
|
|
31279
|
-
}
|
|
31274
|
+
}
|
|
31275
|
+
async function validatePolicy(policy) {
|
|
31280
31276
|
const validateRes = await apiPost(`/tailnet/${getTailnet()}/acl/validate`, void 0, {
|
|
31281
31277
|
rawBody: policy,
|
|
31282
31278
|
contentType: "application/hujson",
|
|
@@ -31292,6 +31288,20 @@ async function deployAcl(filePath) {
|
|
|
31292
31288
|
console.error(`ACL validation failed: ${validationError}`);
|
|
31293
31289
|
process.exit(1);
|
|
31294
31290
|
}
|
|
31291
|
+
}
|
|
31292
|
+
async function validateAcl(filePath) {
|
|
31293
|
+
const policy = readPolicyFile(filePath);
|
|
31294
|
+
await validatePolicy(policy);
|
|
31295
|
+
console.log("ACL policy is valid");
|
|
31296
|
+
}
|
|
31297
|
+
async function deployAcl(filePath) {
|
|
31298
|
+
const policy = readPolicyFile(filePath);
|
|
31299
|
+
const getRes = await apiGet(`/tailnet/${getTailnet()}/acl`, { acceptRaw: true, accept: "application/hujson" });
|
|
31300
|
+
if (!getRes.ok || !getRes.etag) {
|
|
31301
|
+
console.error(`Failed to get current ACL: ${getRes.error || "no ETag returned"}`);
|
|
31302
|
+
process.exit(1);
|
|
31303
|
+
}
|
|
31304
|
+
await validatePolicy(policy);
|
|
31295
31305
|
const deployRes = await apiPost(`/tailnet/${getTailnet()}/acl`, void 0, {
|
|
31296
31306
|
rawBody: policy,
|
|
31297
31307
|
contentType: "application/hujson",
|
|
@@ -31300,7 +31310,13 @@ async function deployAcl(filePath) {
|
|
|
31300
31310
|
accept: "application/hujson"
|
|
31301
31311
|
});
|
|
31302
31312
|
if (!deployRes.ok) {
|
|
31303
|
-
|
|
31313
|
+
if (deployRes.status === 412) {
|
|
31314
|
+
console.error(
|
|
31315
|
+
`ACL deploy failed: the tailnet ACL changed between the ETag fetch and the deploy (concurrent edit). Re-run to retry against the current version. (${deployRes.error})`
|
|
31316
|
+
);
|
|
31317
|
+
} else {
|
|
31318
|
+
console.error(`ACL deploy failed: ${deployRes.error}`);
|
|
31319
|
+
}
|
|
31304
31320
|
process.exit(1);
|
|
31305
31321
|
}
|
|
31306
31322
|
console.log("ACL deployed successfully");
|
|
@@ -33706,15 +33722,16 @@ var webhookTools = [
|
|
|
33706
33722
|
];
|
|
33707
33723
|
|
|
33708
33724
|
// src/index.ts
|
|
33709
|
-
var version2 = true ? "0.
|
|
33725
|
+
var version2 = true ? "0.13.0" : resolveVersionFallback();
|
|
33710
33726
|
var subcommand = process.argv[2];
|
|
33711
|
-
if (subcommand === "deploy-acl") {
|
|
33727
|
+
if (subcommand === "deploy-acl" || subcommand === "validate-acl") {
|
|
33712
33728
|
const filePath = process.argv[3];
|
|
33713
33729
|
if (!filePath) {
|
|
33714
|
-
console.error(
|
|
33730
|
+
console.error(`Usage: tailscale-mcp ${subcommand} <path-to-acl.json>`);
|
|
33715
33731
|
process.exit(1);
|
|
33716
33732
|
}
|
|
33717
|
-
|
|
33733
|
+
const run = subcommand === "deploy-acl" ? deployAcl : validateAcl;
|
|
33734
|
+
await run(filePath).catch((err) => {
|
|
33718
33735
|
console.error(`Fatal: ${err instanceof Error ? err.message : err}`);
|
|
33719
33736
|
process.exit(1);
|
|
33720
33737
|
});
|
|
@@ -33724,7 +33741,7 @@ if (subcommand === "deploy-acl") {
|
|
|
33724
33741
|
process.exit(0);
|
|
33725
33742
|
} else if (subcommand !== void 0) {
|
|
33726
33743
|
console.error(
|
|
33727
|
-
`@yawlabs/tailscale-mcp: unrecognized argument "${subcommand}" -- known subcommands: deploy-acl, version. Starting the MCP server.`
|
|
33744
|
+
`@yawlabs/tailscale-mcp: unrecognized argument "${subcommand}" -- known subcommands: deploy-acl, validate-acl, version. Starting the MCP server.`
|
|
33728
33745
|
);
|
|
33729
33746
|
}
|
|
33730
33747
|
var toolGroups = {
|