@cardelli/ambit 0.3.0 → 0.3.1
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/esm/cli/commands/create/index.js +10 -28
- package/esm/cli/commands/create/machine.d.ts +2 -1
- package/esm/cli/commands/create/machine.d.ts.map +1 -1
- package/esm/cli/commands/create/machine.js +69 -28
- package/esm/deno.js +1 -1
- package/esm/router/start.sh +7 -4
- package/esm/util/constants.d.ts +0 -1
- package/esm/util/constants.d.ts.map +1 -1
- package/esm/util/constants.js +0 -1
- package/package.json +1 -1
|
@@ -12,9 +12,9 @@ import { isPublicTld } from "../../../util/guard.js";
|
|
|
12
12
|
import { createFlyProvider } from "../../../providers/fly.js";
|
|
13
13
|
import { createTailscaleProvider, } from "../../../providers/tailscale.js";
|
|
14
14
|
import { getCredentialStore } from "../../../util/credentials.js";
|
|
15
|
-
import {
|
|
15
|
+
import { TAILSCALE_API_KEY_PREFIX } from "../../../util/constants.js";
|
|
16
16
|
import { resolveOrg } from "../../../util/resolve.js";
|
|
17
|
-
import { assertAdditivePatch, isAutoApproverConfigured, isTagOwnerConfigured,
|
|
17
|
+
import { assertAdditivePatch, isAutoApproverConfigured, isTagOwnerConfigured, patchTagOwner, } from "../../../util/tailscale-local.js";
|
|
18
18
|
import { createTransition, hydrateCreate, reportSkipped, } from "./machine.js";
|
|
19
19
|
// =============================================================================
|
|
20
20
|
// Stage 1: Fly.io Configuration
|
|
@@ -107,40 +107,21 @@ const stageTailscaleConfig = async (out, opts) => {
|
|
|
107
107
|
else {
|
|
108
108
|
tagOwnerSpinner.success(`${opts.tag} Found in Tailscale ACL`);
|
|
109
109
|
}
|
|
110
|
-
if (
|
|
111
|
-
const hasApprover = isAutoApproverConfigured(policy, opts.tag);
|
|
112
|
-
if (!hasApprover && policy) {
|
|
113
|
-
const beforeApprover = policy;
|
|
114
|
-
policy = patchAutoApprover(policy, opts.tag, FLY_PRIVATE_SUBNET);
|
|
115
|
-
assertAdditivePatch(beforeApprover, policy);
|
|
116
|
-
const validateApprover = await tailscale.acl.validatePolicy(policy);
|
|
117
|
-
if (!validateApprover.ok) {
|
|
118
|
-
return handleAclSetFailure(out, validateApprover, `Validating autoApprover patch for ${opts.tag}`);
|
|
119
|
-
}
|
|
120
|
-
const approverSpinner = out.spinner(`Adding autoApprover for ${opts.tag}`);
|
|
121
|
-
const result = await tailscale.acl.setPolicy(policy);
|
|
122
|
-
if (!result.ok) {
|
|
123
|
-
approverSpinner.fail(`Adding autoApprover for ${opts.tag}`);
|
|
124
|
-
return handleAclSetFailure(out, result, `Adding autoApprover for ${opts.tag}`);
|
|
125
|
-
}
|
|
126
|
-
approverSpinner.success(`Added autoApprover for ${opts.tag}`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
else if (opts.json) {
|
|
110
|
+
if (opts.manual && opts.json) {
|
|
130
111
|
const approverSpinner = out.spinner(`Checking autoApprovers for ${opts.tag}`);
|
|
131
112
|
const hasApprover = isAutoApproverConfigured(policy, opts.tag);
|
|
132
113
|
if (!hasApprover) {
|
|
133
114
|
approverSpinner.fail(`Auto-approve Not Configured for ${opts.tag}`);
|
|
134
115
|
out.blank()
|
|
135
|
-
.text(" In
|
|
136
|
-
.text(
|
|
116
|
+
.text(" In --manual --json mode, ambit can't interactively approve the")
|
|
117
|
+
.text(" router's subnet routes. Set up autoApprovers first:")
|
|
137
118
|
.link(" https://login.tailscale.com/admin/acls/visual/auto-approvers")
|
|
138
|
-
.dim(` Route:
|
|
119
|
+
.dim(` Route: <subnet>/48 Owner: ${opts.tag}`)
|
|
139
120
|
.blank()
|
|
140
|
-
.dim(" Or
|
|
141
|
-
.dim(` "autoApprovers": { "routes": { "
|
|
121
|
+
.dim(" Or in the ACL file:")
|
|
122
|
+
.dim(` "autoApprovers": { "routes": { "<subnet>/48": ["${opts.tag}"] } }`)
|
|
142
123
|
.blank();
|
|
143
|
-
return out.die(`Set Up Auto-approve for ${opts.tag} to Use --json`);
|
|
124
|
+
return out.die(`Set Up Auto-approve for ${opts.tag} to Use --manual --json`);
|
|
144
125
|
}
|
|
145
126
|
approverSpinner.success(`Auto-approve Configured for ${opts.tag}`);
|
|
146
127
|
}
|
|
@@ -334,6 +315,7 @@ ${bold("EXAMPLES")}
|
|
|
334
315
|
region,
|
|
335
316
|
tag,
|
|
336
317
|
shouldApprove,
|
|
318
|
+
manual,
|
|
337
319
|
});
|
|
338
320
|
};
|
|
339
321
|
// =============================================================================
|
|
@@ -3,7 +3,7 @@ import { Result } from "../../../lib/result.js";
|
|
|
3
3
|
import { type FlyProvider } from "../../../providers/fly.js";
|
|
4
4
|
import type { TailscaleProvider } from "../../../providers/tailscale.js";
|
|
5
5
|
import type { TailscaleDevice } from "../../../schemas/tailscale.js";
|
|
6
|
-
export type CreatePhase = "create_app" | "deploy_router" | "
|
|
6
|
+
export type CreatePhase = "create_app" | "deploy_router" | "approve_routes" | "configure_dns" | "accept_routes" | "complete";
|
|
7
7
|
export type CreateResult = {
|
|
8
8
|
network: string;
|
|
9
9
|
router: {
|
|
@@ -22,6 +22,7 @@ export interface CreateCtx {
|
|
|
22
22
|
region: string;
|
|
23
23
|
tag: string;
|
|
24
24
|
shouldApprove: boolean;
|
|
25
|
+
manual: boolean;
|
|
25
26
|
appName: string;
|
|
26
27
|
routerId: string;
|
|
27
28
|
device?: TailscaleDevice;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"machine.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/create/machine.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAQhD,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"machine.d.ts","sourceRoot":"","sources":["../../../../src/cli/commands/create/machine.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAQhD,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAW7E,OAAO,KAAK,EAAgB,iBAAiB,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAOrE,MAAM,MAAM,WAAW,GACnB,YAAY,GACZ,eAAe,GACf,gBAAgB,GAChB,eAAe,GACf,eAAe,GACf,UAAU,CAAC;AAMf,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACxD,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,WAAW,SAAS;IACxB,GAAG,EAAE,WAAW,CAAC;IACjB,SAAS,EAAE,iBAAiB,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC,YAAY,CAAC,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,aAAa,EAAE,OAAO,CAAC;IACvB,MAAM,EAAE,OAAO,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAcD,eAAO,MAAM,aAAa,GACxB,KAAK,MAAM,CAAC,YAAY,CAAC,EACzB,YAAY,WAAW,SAMxB,CAAC;AAMF,eAAO,MAAM,aAAa,GACxB,KAAK,SAAS,KACb,OAAO,CAAC,WAAW,CA4BrB,CAAC;AAMF,eAAO,MAAM,gBAAgB,GAC3B,OAAO,WAAW,EAClB,KAAK,SAAS,KACb,OAAO,CAAC,MAAM,CAAC,WAAW,CAAC,CA6K7B,CAAC"}
|
|
@@ -7,7 +7,7 @@ import { extractSubnet } from "../../../util/fly-transforms.js";
|
|
|
7
7
|
import { ROUTER_DOCKER_DIR, SECRET_NETWORK_NAME, SECRET_ROUTER_ID, SECRET_TAILSCALE_AUTHKEY, } from "../../../util/constants.js";
|
|
8
8
|
import { FlyDeployError } from "../../../providers/fly.js";
|
|
9
9
|
import { getRouterAppName } from "../../../util/naming.js";
|
|
10
|
-
import { enableAcceptRoutes, isAcceptRoutesEnabled, isAutoApproverConfigured, isTailscaleInstalled, waitForDevice, } from "../../../util/tailscale-local.js";
|
|
10
|
+
import { assertAdditivePatch, enableAcceptRoutes, isAcceptRoutesEnabled, isAutoApproverConfigured, isTailscaleInstalled, patchAutoApprover, waitForDevice, } from "../../../util/tailscale-local.js";
|
|
11
11
|
import { findRouterApp, getRouterMachineInfo } from "../../../util/discovery.js";
|
|
12
12
|
// =============================================================================
|
|
13
13
|
// Phase Labels
|
|
@@ -15,7 +15,6 @@ import { findRouterApp, getRouterMachineInfo } from "../../../util/discovery.js"
|
|
|
15
15
|
const CREATE_PHASES = [
|
|
16
16
|
{ phase: "create_app", label: "Fly App Created" },
|
|
17
17
|
{ phase: "deploy_router", label: "Router Deployed" },
|
|
18
|
-
{ phase: "await_device", label: "Router in Tailnet" },
|
|
19
18
|
{ phase: "approve_routes", label: "Routes Approved" },
|
|
20
19
|
{ phase: "configure_dns", label: "Split DNS Configured" },
|
|
21
20
|
{ phase: "accept_routes", label: "Accept Routes Enabled" },
|
|
@@ -44,7 +43,7 @@ export const hydrateCreate = async (ctx) => {
|
|
|
44
43
|
return "complete";
|
|
45
44
|
const device = await ctx.tailscale.devices.getByHostname(router.appName);
|
|
46
45
|
if (!device)
|
|
47
|
-
return "
|
|
46
|
+
return "approve_routes";
|
|
48
47
|
ctx.device = device;
|
|
49
48
|
const routes = await ctx.tailscale.routes.get(device.id);
|
|
50
49
|
if (!routes || routes.unapproved.length > 0)
|
|
@@ -70,15 +69,7 @@ export const createTransition = async (phase, ctx) => {
|
|
|
70
69
|
return Result.ok("deploy_router");
|
|
71
70
|
}
|
|
72
71
|
case "deploy_router": {
|
|
73
|
-
|
|
74
|
-
reusable: false,
|
|
75
|
-
ephemeral: false,
|
|
76
|
-
preauthorized: true,
|
|
77
|
-
tags: [ctx.tag],
|
|
78
|
-
});
|
|
79
|
-
ctx.out.ok("Auth Key Created");
|
|
80
|
-
await ctx.out.spin("Setting Secrets", () => ctx.fly.secrets.set(ctx.appName, {
|
|
81
|
-
[SECRET_TAILSCALE_AUTHKEY]: authKey,
|
|
72
|
+
await ctx.out.spin("Staging Secrets", () => ctx.fly.secrets.set(ctx.appName, {
|
|
82
73
|
[SECRET_NETWORK_NAME]: ctx.network,
|
|
83
74
|
[SECRET_ROUTER_ID]: ctx.routerId,
|
|
84
75
|
}, { stage: true }));
|
|
@@ -102,34 +93,84 @@ export const createTransition = async (phase, ctx) => {
|
|
|
102
93
|
const m = machines.find((m) => m.private_ip);
|
|
103
94
|
if (m?.private_ip)
|
|
104
95
|
ctx.subnet = extractSubnet(m.private_ip);
|
|
105
|
-
|
|
106
|
-
return Result.ok("complete");
|
|
107
|
-
return Result.ok("await_device");
|
|
96
|
+
return Result.ok("approve_routes");
|
|
108
97
|
}
|
|
109
|
-
case "
|
|
110
|
-
ctx.device = await waitForDevice(ctx.tailscale, ctx.appName, 180000);
|
|
111
|
-
ctx.out.ok(`Router Joined Tailnet: ${ctx.device.addresses[0]}`);
|
|
98
|
+
case "approve_routes": {
|
|
112
99
|
if (!ctx.subnet) {
|
|
113
100
|
const machines = await ctx.fly.machines.list(ctx.appName);
|
|
114
101
|
const m = machines.find((m) => m.private_ip);
|
|
115
102
|
if (m?.private_ip)
|
|
116
103
|
ctx.subnet = extractSubnet(m.private_ip);
|
|
117
104
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (!ctx.device || !ctx.subnet) {
|
|
122
|
-
return Result.err("Missing Device or Subnet");
|
|
123
|
-
}
|
|
124
|
-
const policy = await ctx.tailscale.acl.getPolicy();
|
|
105
|
+
if (!ctx.subnet)
|
|
106
|
+
return Result.err("Missing Subnet");
|
|
107
|
+
let policy = await ctx.tailscale.acl.getPolicy();
|
|
125
108
|
const hasAutoApprover = isAutoApproverConfigured(policy, ctx.tag);
|
|
126
|
-
|
|
127
|
-
|
|
109
|
+
let approverReady = hasAutoApprover;
|
|
110
|
+
if (!hasAutoApprover && !ctx.manual && policy) {
|
|
111
|
+
const before = policy;
|
|
112
|
+
policy = patchAutoApprover(policy, ctx.tag, ctx.subnet);
|
|
113
|
+
assertAdditivePatch(before, policy);
|
|
114
|
+
const vr = await ctx.tailscale.acl.validatePolicy(policy);
|
|
115
|
+
if (!vr.ok) {
|
|
116
|
+
ctx.out.warn(`Could Not Validate autoApprover Patch: ${vr.error ?? `HTTP ${vr.status}`}`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const sr = await ctx.tailscale.acl.setPolicy(policy);
|
|
120
|
+
if (sr.ok) {
|
|
121
|
+
ctx.out.ok(`Added autoApprover for ${ctx.tag} → ${ctx.subnet}`);
|
|
122
|
+
approverReady = true;
|
|
123
|
+
}
|
|
124
|
+
else if (sr.status === 403) {
|
|
125
|
+
ctx.out.warn("API Token Lacks ACL Write Permission — Will Approve Routes Manually");
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
ctx.out.warn(`Could Not Set autoApprover: ${sr.error ?? `HTTP ${sr.status}`}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
128
131
|
}
|
|
129
|
-
|
|
132
|
+
// If the device isn't in the tailnet yet, the router hasn't
|
|
133
|
+
// authenticated. Mint an auth key and deliver it — the non-staged
|
|
134
|
+
// secrets set triggers a Fly restart. The router boots with the key,
|
|
135
|
+
// authenticates, and advertises routes. With autoApprover in place,
|
|
136
|
+
// routes are auto-approved immediately.
|
|
137
|
+
if (!ctx.device) {
|
|
138
|
+
const existing = await ctx.tailscale.devices.getByHostname(ctx.appName);
|
|
139
|
+
if (existing) {
|
|
140
|
+
ctx.device = existing;
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const authKey = await ctx.tailscale.auth.createKey({
|
|
144
|
+
reusable: false,
|
|
145
|
+
ephemeral: false,
|
|
146
|
+
preauthorized: true,
|
|
147
|
+
tags: [ctx.tag],
|
|
148
|
+
});
|
|
149
|
+
ctx.out.ok("Auth Key Created");
|
|
150
|
+
const keySpinner = ctx.out.spinner("Delivering Auth Key (restarting router)");
|
|
151
|
+
await ctx.fly.secrets.set(ctx.appName, {
|
|
152
|
+
[SECRET_TAILSCALE_AUTHKEY]: authKey,
|
|
153
|
+
});
|
|
154
|
+
keySpinner.success("Auth Key Delivered");
|
|
155
|
+
if (!ctx.shouldApprove) {
|
|
156
|
+
ctx.out.dim(" Skipping Route Approval (--no-auto-approve)");
|
|
157
|
+
return Result.ok("complete");
|
|
158
|
+
}
|
|
159
|
+
ctx.device = await waitForDevice(ctx.tailscale, ctx.appName, 180000);
|
|
160
|
+
ctx.out.ok(`Router Joined Tailnet: ${ctx.device.addresses[0]}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const routes = await ctx.tailscale.routes.get(ctx.device.id);
|
|
164
|
+
if (routes && routes.unapproved.length > 0) {
|
|
165
|
+
if (approverReady) {
|
|
166
|
+
ctx.out.warn("Routes Not Auto-Approved Despite autoApprover — Approving Manually");
|
|
167
|
+
}
|
|
130
168
|
await ctx.tailscale.routes.approve(ctx.device.id, [ctx.subnet]);
|
|
131
169
|
ctx.out.ok("Subnet Routes Approved");
|
|
132
170
|
}
|
|
171
|
+
else {
|
|
172
|
+
ctx.out.ok("Routes Auto-Approved via ACL Policy");
|
|
173
|
+
}
|
|
133
174
|
return Result.ok("configure_dns");
|
|
134
175
|
}
|
|
135
176
|
case "configure_dns": {
|
package/esm/deno.js
CHANGED
package/esm/router/start.sh
CHANGED
|
@@ -5,7 +5,8 @@ set -e
|
|
|
5
5
|
# ambit - Self-Configuring Tailscale Subnet Router
|
|
6
6
|
# =============================================================================
|
|
7
7
|
# State is persisted to /var/lib/tailscale via Fly volume.
|
|
8
|
-
# On first run:
|
|
8
|
+
# On first run: waits for TAILSCALE_AUTHKEY (delivered by the CLI after
|
|
9
|
+
# autoApprovers are in place), then authenticates and advertises routes.
|
|
9
10
|
# On restart: reuses existing state, no new device created.
|
|
10
11
|
# The router never receives the user's API token — only a single-use,
|
|
11
12
|
# tag-scoped auth key that expires after 5 minutes.
|
|
@@ -41,10 +42,12 @@ if /usr/local/bin/tailscale status --json 2>/dev/null | jq -e '.BackendState ==
|
|
|
41
42
|
--hostname="${FLY_APP_NAME:-ambit}" \
|
|
42
43
|
--advertise-routes="${SUBNET}"
|
|
43
44
|
else
|
|
44
|
-
# First run
|
|
45
|
+
# First run — auth key is delivered by the CLI via `fly secrets set` after
|
|
46
|
+
# autoApprovers are configured. If it's not here yet, wait for the restart
|
|
47
|
+
# that the non-staged secrets set triggers.
|
|
45
48
|
if [ -z "${TAILSCALE_AUTHKEY}" ]; then
|
|
46
|
-
echo "Router:
|
|
47
|
-
|
|
49
|
+
echo "Router: Waiting for Auth Key (CLI will deliver via secrets)"
|
|
50
|
+
while true; do sleep 5; done
|
|
48
51
|
fi
|
|
49
52
|
|
|
50
53
|
echo "Router: Authenticating to Tailscale"
|
package/esm/util/constants.d.ts
CHANGED
|
@@ -2,7 +2,6 @@ export declare const ROUTER_APP_PREFIX = "ambit-";
|
|
|
2
2
|
export declare const DEFAULT_FLY_NETWORK = "default";
|
|
3
3
|
export declare const ROUTER_DOCKER_DIR: string;
|
|
4
4
|
export declare const SOCKS_PROXY_PORT = 1080;
|
|
5
|
-
export declare const FLY_PRIVATE_SUBNET = "fdaa::/16";
|
|
6
5
|
export declare const TAILSCALE_API_KEY_PREFIX = "tskey-api-";
|
|
7
6
|
export declare const ENV_TAILSCALE_API_KEY = "TAILSCALE_API_KEY";
|
|
8
7
|
export declare const FLYCTL_INSTALL_URL = "https://fly.io/docs/flyctl/install/";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/util/constants.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,iBAAiB,WAAW,CAAC;AAE1C,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAE7C,eAAO,MAAM,iBAAiB,QACnB,CAAC;AAMZ,eAAO,MAAM,gBAAgB,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/util/constants.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,iBAAiB,WAAW,CAAC;AAE1C,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAE7C,eAAO,MAAM,iBAAiB,QACnB,CAAC;AAMZ,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAMrC,eAAO,MAAM,wBAAwB,eAAe,CAAC;AAErD,eAAO,MAAM,qBAAqB,sBAAsB,CAAC;AAMzD,eAAO,MAAM,kBAAkB,wCAAwC,CAAC;AAMxE,eAAO,MAAM,wBAAwB,sBAAsB,CAAC;AAC5D,eAAO,MAAM,mBAAmB,iBAAiB,CAAC;AAClD,eAAO,MAAM,gBAAgB,cAAc,CAAC;AAM5C,eAAO,MAAM,2BAA2B,yBAAyB,CAAC"}
|
package/esm/util/constants.js
CHANGED
|
@@ -12,7 +12,6 @@ export const ROUTER_DOCKER_DIR = new URL("../router", globalThis[Symbol.for("imp
|
|
|
12
12
|
// Networking
|
|
13
13
|
// =============================================================================
|
|
14
14
|
export const SOCKS_PROXY_PORT = 1080;
|
|
15
|
-
export const FLY_PRIVATE_SUBNET = "fdaa::/16";
|
|
16
15
|
// =============================================================================
|
|
17
16
|
// Tailscale
|
|
18
17
|
// =============================================================================
|