@saadnvd1/agent-os 0.3.514 → 0.3.516
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 +13 -1
- package/app/api/devices/me/push/route.test.ts +209 -0
- package/app/api/devices/me/push/route.ts +115 -0
- package/app/api/devices/me/route.ts +12 -0
- package/lib/connect/serve.ts +107 -31
- package/lib/connect/supervise.ts +110 -11
- package/lib/db/migrations.ts +28 -0
- package/lib/notify/copy.ts +190 -0
- package/lib/notify/push-types.ts +77 -0
- package/lib/notify/redact.ts +57 -0
- package/lib/security/demo.ts +1 -0
- package/lib/security/device-me.ts +12 -0
- package/lib/security/devices.ts +156 -1
- package/lib/security/net.ts +47 -11
- package/package.json +1 -1
- package/server.ts +1 -0
package/README.md
CHANGED
|
@@ -1585,7 +1585,13 @@ device can use AgentOS but can't add devices or change access; only this
|
|
|
1585
1585
|
machine and the tailnet can.
|
|
1586
1586
|
|
|
1587
1587
|
AgentOS also refuses requests addressed to any other host name (DNS rebinding),
|
|
1588
|
-
and API calls or terminal connections made by another website's page.
|
|
1588
|
+
and API calls or terminal connections made by another website's page. That
|
|
1589
|
+
Origin check holds whatever `AGENTOS_BIND` says: a page may write to the API
|
|
1590
|
+
or open a terminal, chat or status socket only from this machine's own
|
|
1591
|
+
addresses (loopback, the addresses it listens on, its tailnet name, its own
|
|
1592
|
+
Connect address and `AGENTOS_ALLOWED_HOSTS`). Another Connect machine's page
|
|
1593
|
+
is always refused. With `AGENTOS_BIND=0.0.0.0`, reaching it by a DNS name
|
|
1594
|
+
other than these needs that name in `AGENTOS_ALLOWED_HOSTS`.
|
|
1589
1595
|
|
|
1590
1596
|
| Variable | Effect |
|
|
1591
1597
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
@@ -1613,6 +1619,12 @@ While Connect is on, the production server:
|
|
|
1613
1619
|
- asks the Connect service for a fresh connector token on every start and hands
|
|
1614
1620
|
it to `cloudflared tunnel run` in `TUNNEL_TOKEN` only, never in argv, a file
|
|
1615
1621
|
or a log;
|
|
1622
|
+
- starts cloudflared only once AgentOS holds the tunnel port and has seen a
|
|
1623
|
+
connection to it arrive on its own listener, and stops it if the listener
|
|
1624
|
+
goes, so it never publishes something else on that port;
|
|
1625
|
+
- runs cloudflared in a process group of its own under a small watchdog that
|
|
1626
|
+
kills it the moment AgentOS exits, however it exits, and kills one a crash
|
|
1627
|
+
left behind (from `cloudflared.pid`) before starting another;
|
|
1616
1628
|
- restarts cloudflared with backoff if it exits, and stops it when Connect is
|
|
1617
1629
|
turned off. Devices shows its state.
|
|
1618
1630
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { DELETE, PATCH, PUT } from "./route";
|
|
4
|
+
import { DELETE as SELF_DELETE } from "../route";
|
|
5
|
+
import {
|
|
6
|
+
deviceForToken,
|
|
7
|
+
getDevicePush,
|
|
8
|
+
mintDevice,
|
|
9
|
+
revokeDevice,
|
|
10
|
+
} from "@/lib/security/devices";
|
|
11
|
+
import { DEVICE_HEADER } from "@/lib/security/auth";
|
|
12
|
+
import { getDb } from "@/lib/db";
|
|
13
|
+
import { defaultCategories } from "@/lib/notify/copy";
|
|
14
|
+
|
|
15
|
+
const call = (
|
|
16
|
+
fn: (r: NextRequest) => Promise<Response>,
|
|
17
|
+
method: string,
|
|
18
|
+
device: string | null,
|
|
19
|
+
body?: unknown
|
|
20
|
+
) =>
|
|
21
|
+
fn(
|
|
22
|
+
new NextRequest("http://x/api/devices/me/push", {
|
|
23
|
+
method,
|
|
24
|
+
headers: device ? { [DEVICE_HEADER]: device } : {},
|
|
25
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const tok = (n: number) => "apns" + String(n).padStart(8, "0") + "ab".repeat(8);
|
|
30
|
+
|
|
31
|
+
describe("/api/devices/me/push", () => {
|
|
32
|
+
it("refuses a request that isn't from a paired device", async () => {
|
|
33
|
+
for (const [fn, m] of [
|
|
34
|
+
[PUT, "PUT"],
|
|
35
|
+
[PATCH, "PATCH"],
|
|
36
|
+
[DELETE, "DELETE"],
|
|
37
|
+
] as const)
|
|
38
|
+
expect((await call(fn, m, null, { token: tok(1) })).status).toBe(403);
|
|
39
|
+
expect(
|
|
40
|
+
(
|
|
41
|
+
await SELF_DELETE(
|
|
42
|
+
new NextRequest("http://x/api/devices/me", { method: "DELETE" })
|
|
43
|
+
)
|
|
44
|
+
).status
|
|
45
|
+
).toBe(403);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("registers, defaults the categories, and is idempotent", async () => {
|
|
49
|
+
const { device } = mintDevice("phone");
|
|
50
|
+
const res = await call(PUT, "PUT", device.id, {
|
|
51
|
+
token: tok(2),
|
|
52
|
+
environment: "sandbox",
|
|
53
|
+
});
|
|
54
|
+
expect(res.status).toBe(200);
|
|
55
|
+
expect(await res.json()).toMatchObject({
|
|
56
|
+
deviceId: device.id,
|
|
57
|
+
enabled: true,
|
|
58
|
+
platform: "ios",
|
|
59
|
+
environment: "sandbox",
|
|
60
|
+
categories: defaultCategories(),
|
|
61
|
+
});
|
|
62
|
+
const again = await call(PUT, "PUT", device.id, {
|
|
63
|
+
token: tok(2),
|
|
64
|
+
environment: "sandbox",
|
|
65
|
+
});
|
|
66
|
+
expect(again.status).toBe(200);
|
|
67
|
+
expect(getDevicePush(device.id)?.token).toBe(tok(2));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a new token replaces the old and keeps saved categories", async () => {
|
|
71
|
+
const { device } = mintDevice("phone");
|
|
72
|
+
await call(PUT, "PUT", device.id, { token: tok(3) });
|
|
73
|
+
await call(PATCH, "PATCH", device.id, { categories: ["asks", "merged"] });
|
|
74
|
+
await call(PUT, "PUT", device.id, { token: tok(4) });
|
|
75
|
+
expect(getDevicePush(device.id)).toMatchObject({
|
|
76
|
+
token: tok(4),
|
|
77
|
+
categories: ["asks", "merged"],
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("moves a token from the device that held it", async () => {
|
|
82
|
+
const a = mintDevice("old pairing").device;
|
|
83
|
+
const b = mintDevice("new pairing").device;
|
|
84
|
+
await call(PUT, "PUT", a.id, { token: tok(5) });
|
|
85
|
+
await call(PUT, "PUT", b.id, { token: tok(5) });
|
|
86
|
+
expect(getDevicePush(a.id)).toBeNull();
|
|
87
|
+
expect(getDevicePush(b.id)?.token).toBe(tok(5));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects bad input", async () => {
|
|
91
|
+
const { device } = mintDevice("phone");
|
|
92
|
+
for (const b of [
|
|
93
|
+
{},
|
|
94
|
+
{ token: "short" },
|
|
95
|
+
{ token: "bad token with spaces!!" },
|
|
96
|
+
{ token: tok(6), platform: "palm" },
|
|
97
|
+
{ token: tok(6), environment: "staging" },
|
|
98
|
+
{ token: tok(6), categories: ["asks", "nope"] },
|
|
99
|
+
{ token: tok(6), categories: "asks" },
|
|
100
|
+
])
|
|
101
|
+
expect((await call(PUT, "PUT", device.id, b)).status).toBe(400);
|
|
102
|
+
expect(getDevicePush(device.id)).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("PATCH sets categories, needs a registration, and validates", async () => {
|
|
106
|
+
const { device } = mintDevice("phone");
|
|
107
|
+
expect(
|
|
108
|
+
(await call(PATCH, "PATCH", device.id, { categories: ["asks"] })).status
|
|
109
|
+
).toBe(404);
|
|
110
|
+
await call(PUT, "PUT", device.id, { token: tok(7) });
|
|
111
|
+
expect(
|
|
112
|
+
(await call(PATCH, "PATCH", device.id, { categories: ["x"] })).status
|
|
113
|
+
).toBe(400);
|
|
114
|
+
expect((await call(PATCH, "PATCH", device.id, {})).status).toBe(400);
|
|
115
|
+
const res = await call(PATCH, "PATCH", device.id, {
|
|
116
|
+
categories: ["ci", "ci"],
|
|
117
|
+
});
|
|
118
|
+
expect((await res.json()).categories).toEqual(["ci"]);
|
|
119
|
+
await call(PATCH, "PATCH", device.id, { categories: [] });
|
|
120
|
+
expect(getDevicePush(device.id)?.categories).toEqual([]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("DELETE clears push for this device only", async () => {
|
|
124
|
+
const a = mintDevice("a").device;
|
|
125
|
+
const b = mintDevice("b").device;
|
|
126
|
+
await call(PUT, "PUT", a.id, { token: tok(8) });
|
|
127
|
+
await call(PUT, "PUT", b.id, { token: tok(9) });
|
|
128
|
+
const res = await call(DELETE, "DELETE", a.id);
|
|
129
|
+
expect(await res.json()).toMatchObject({ enabled: false });
|
|
130
|
+
expect(getDevicePush(a.id)).toBeNull();
|
|
131
|
+
expect(getDevicePush(b.id)).not.toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("refuses an oversized body", async () => {
|
|
135
|
+
const { device } = mintDevice("phone");
|
|
136
|
+
const res = await call(PUT, "PUT", device.id, {
|
|
137
|
+
token: tok(14),
|
|
138
|
+
pad: "x".repeat(5000),
|
|
139
|
+
});
|
|
140
|
+
expect(res.status).toBe(413);
|
|
141
|
+
expect(getDevicePush(device.id)).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reads a corrupt saved category list as the defaults", async () => {
|
|
145
|
+
const { device } = mintDevice("phone");
|
|
146
|
+
await call(PUT, "PUT", device.id, { token: tok(15) });
|
|
147
|
+
for (const junk of ["{not json", '"asks"', "7"]) {
|
|
148
|
+
getDb()
|
|
149
|
+
.prepare(`UPDATE devices SET push_categories = ? WHERE id = ?`)
|
|
150
|
+
.run(junk, device.id);
|
|
151
|
+
expect(getDevicePush(device.id)?.categories).toEqual(defaultCategories());
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("never returns the token", async () => {
|
|
156
|
+
const { device } = mintDevice("phone");
|
|
157
|
+
const res = await call(PUT, "PUT", device.id, { token: tok(10) });
|
|
158
|
+
expect(JSON.stringify(await res.json())).not.toContain(tok(10));
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("device revocation and push", () => {
|
|
163
|
+
it("revoking clears every push column", async () => {
|
|
164
|
+
const { device } = mintDevice("phone");
|
|
165
|
+
await call(PUT, "PUT", device.id, { token: tok(11), categories: ["asks"] });
|
|
166
|
+
expect(revokeDevice(device.id)).toBe(true);
|
|
167
|
+
const row = getDb()
|
|
168
|
+
.prepare(
|
|
169
|
+
`SELECT push_token, push_platform, push_environment, push_categories,
|
|
170
|
+
push_updated_at, push_last_error FROM devices WHERE id = ?`
|
|
171
|
+
)
|
|
172
|
+
.get(device.id);
|
|
173
|
+
expect(Object.values(row as object).every((v) => v === null)).toBe(true);
|
|
174
|
+
expect(getDevicePush(device.id)).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("DELETE /api/devices/me revokes the caller and its push", async () => {
|
|
178
|
+
const { device, token } = mintDevice("phone");
|
|
179
|
+
await call(PUT, "PUT", device.id, { token: tok(12) });
|
|
180
|
+
const res = await SELF_DELETE(
|
|
181
|
+
new NextRequest("http://x/api/devices/me", {
|
|
182
|
+
method: "DELETE",
|
|
183
|
+
headers: { [DEVICE_HEADER]: device.id },
|
|
184
|
+
})
|
|
185
|
+
);
|
|
186
|
+
expect(res.status).toBe(200);
|
|
187
|
+
expect(deviceForToken(token)).toBeNull();
|
|
188
|
+
expect(getDevicePush(device.id)).toBeNull();
|
|
189
|
+
// A second sign-out finds nothing.
|
|
190
|
+
expect(
|
|
191
|
+
(
|
|
192
|
+
await SELF_DELETE(
|
|
193
|
+
new NextRequest("http://x/api/devices/me", {
|
|
194
|
+
method: "DELETE",
|
|
195
|
+
headers: { [DEVICE_HEADER]: device.id },
|
|
196
|
+
})
|
|
197
|
+
)
|
|
198
|
+
).status
|
|
199
|
+
).toBe(404);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("a revoked device can't register push", async () => {
|
|
203
|
+
const { device } = mintDevice("phone");
|
|
204
|
+
revokeDevice(device.id);
|
|
205
|
+
expect((await call(PUT, "PUT", device.id, { token: tok(13) })).status).toBe(
|
|
206
|
+
404
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
clearDevicePush,
|
|
4
|
+
getDevicePush,
|
|
5
|
+
setDevicePush,
|
|
6
|
+
setDevicePushCategories,
|
|
7
|
+
} from "@/lib/security/devices";
|
|
8
|
+
import { callingDevice, needsDevice } from "@/lib/security/device-me";
|
|
9
|
+
import { readCapped } from "@/lib/security/read-capped";
|
|
10
|
+
import { isPushCategory } from "@/lib/notify/copy";
|
|
11
|
+
import {
|
|
12
|
+
PUSH_ENVIRONMENTS,
|
|
13
|
+
PUSH_PLATFORMS,
|
|
14
|
+
type PushCategory,
|
|
15
|
+
type PushEnvironment,
|
|
16
|
+
type PushPlatform,
|
|
17
|
+
} from "@/lib/notify/push-types";
|
|
18
|
+
|
|
19
|
+
const bad = (error: string) => NextResponse.json({ error }, { status: 400 });
|
|
20
|
+
|
|
21
|
+
const MAX_BODY_BYTES = 4096;
|
|
22
|
+
|
|
23
|
+
/** The JSON body, or null when it's over the cap. Bad JSON reads as empty. */
|
|
24
|
+
async function body(request: NextRequest) {
|
|
25
|
+
const text = await readCapped(request, MAX_BODY_BYTES);
|
|
26
|
+
if (text === null) return null;
|
|
27
|
+
try {
|
|
28
|
+
const v = JSON.parse(text || "{}");
|
|
29
|
+
return (v && typeof v === "object" ? v : {}) as Record<string, unknown>;
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const tooBig = () => NextResponse.json({ error: "Too large" }, { status: 413 });
|
|
36
|
+
|
|
37
|
+
/** Undefined if absent, null if present but invalid. */
|
|
38
|
+
function categoriesOf(v: unknown): PushCategory[] | undefined | null {
|
|
39
|
+
if (v === undefined) return undefined;
|
|
40
|
+
if (!Array.isArray(v) || !v.every(isPushCategory)) return null;
|
|
41
|
+
return [...new Set(v)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const view = (id: string) => {
|
|
45
|
+
const p = getDevicePush(id);
|
|
46
|
+
return {
|
|
47
|
+
deviceId: id,
|
|
48
|
+
enabled: !!p,
|
|
49
|
+
categories: p?.categories ?? [],
|
|
50
|
+
environment: p?.environment ?? null,
|
|
51
|
+
platform: p?.platform ?? null,
|
|
52
|
+
lastError: p?.lastError ?? null,
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// PUT /api/devices/me/push - register or refresh this device's push token
|
|
57
|
+
export async function PUT(request: NextRequest) {
|
|
58
|
+
const id = callingDevice(request);
|
|
59
|
+
if (!id) return needsDevice();
|
|
60
|
+
const b = await body(request);
|
|
61
|
+
if (!b) return tooBig();
|
|
62
|
+
const token = b.token;
|
|
63
|
+
if (
|
|
64
|
+
typeof token !== "string" ||
|
|
65
|
+
token.length < 8 ||
|
|
66
|
+
token.length > 512 ||
|
|
67
|
+
!/^[A-Za-z0-9:_.\-]+$/.test(token)
|
|
68
|
+
)
|
|
69
|
+
return bad("token must be a device push token");
|
|
70
|
+
const platform = b.platform ?? "ios";
|
|
71
|
+
const environment = b.environment ?? "production";
|
|
72
|
+
if (!PUSH_PLATFORMS.includes(platform as PushPlatform))
|
|
73
|
+
return bad("platform must be ios or android");
|
|
74
|
+
if (!PUSH_ENVIRONMENTS.includes(environment as PushEnvironment))
|
|
75
|
+
return bad("environment must be production or sandbox");
|
|
76
|
+
const categories = categoriesOf(b.categories);
|
|
77
|
+
if (categories === null) return bad("categories must be known names");
|
|
78
|
+
// Intended: a phone re-paired to this machine gets a new device row and the
|
|
79
|
+
// same APNs token, which must move to it so the old row stops getting sends.
|
|
80
|
+
// Only a holder of a valid device token can register one.
|
|
81
|
+
if (
|
|
82
|
+
!setDevicePush(id, {
|
|
83
|
+
token,
|
|
84
|
+
platform: platform as PushPlatform,
|
|
85
|
+
environment: environment as PushEnvironment,
|
|
86
|
+
categories,
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
90
|
+
return NextResponse.json(view(id));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// PATCH /api/devices/me/push - choose which categories to receive
|
|
94
|
+
export async function PATCH(request: NextRequest) {
|
|
95
|
+
const id = callingDevice(request);
|
|
96
|
+
if (!id) return needsDevice();
|
|
97
|
+
const b = await body(request);
|
|
98
|
+
if (!b) return tooBig();
|
|
99
|
+
const categories = categoriesOf(b.categories);
|
|
100
|
+
if (!categories) return bad("categories must be a list of known names");
|
|
101
|
+
if (!setDevicePushCategories(id, categories))
|
|
102
|
+
return NextResponse.json(
|
|
103
|
+
{ error: "Push isn't registered" },
|
|
104
|
+
{ status: 404 }
|
|
105
|
+
);
|
|
106
|
+
return NextResponse.json(view(id));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// DELETE /api/devices/me/push - stop pushing to this device
|
|
110
|
+
export async function DELETE(request: NextRequest) {
|
|
111
|
+
const id = callingDevice(request);
|
|
112
|
+
if (!id) return needsDevice();
|
|
113
|
+
clearDevicePush(id);
|
|
114
|
+
return NextResponse.json(view(id));
|
|
115
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { revokeDevice } from "@/lib/security/devices";
|
|
3
|
+
import { callingDevice, needsDevice } from "@/lib/security/device-me";
|
|
4
|
+
|
|
5
|
+
// DELETE /api/devices/me - the device signs itself out ("Forget machine")
|
|
6
|
+
export async function DELETE(request: NextRequest) {
|
|
7
|
+
const id = callingDevice(request);
|
|
8
|
+
if (!id) return needsDevice();
|
|
9
|
+
if (!revokeDevice(id))
|
|
10
|
+
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
11
|
+
return NextResponse.json({ ok: true });
|
|
12
|
+
}
|
package/lib/connect/serve.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* - takes only the machine's own Host (cloudflared pins it) and strips
|
|
9
9
|
* X-Forwarded-Host, Forwarded and X-Original-Host before any handler.
|
|
10
10
|
*
|
|
11
|
+
* cloudflared starts only once that listener is bound and a connection to
|
|
12
|
+
* the port is seen to arrive on it, and stops if the listener goes: it must
|
|
13
|
+
* never publish whatever else holds the port.
|
|
14
|
+
*
|
|
11
15
|
* Connect follows connect.json while running: `agent-os disconnect` or the
|
|
12
16
|
* switch in Devices stops cloudflared and closes every tunnel connection at
|
|
13
17
|
* once. Nothing here may stop AgentOS from starting.
|
|
@@ -15,6 +19,7 @@
|
|
|
15
19
|
|
|
16
20
|
import fs from "fs";
|
|
17
21
|
import http from "http";
|
|
22
|
+
import net from "net";
|
|
18
23
|
import path from "path";
|
|
19
24
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
20
25
|
import type { Socket } from "net";
|
|
@@ -36,7 +41,13 @@ import {
|
|
|
36
41
|
} from "./control-api";
|
|
37
42
|
import { appVersion, startHeartbeat } from "./heartbeat";
|
|
38
43
|
import { ensureCloudflared } from "./cloudflared";
|
|
39
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
reapTunnel,
|
|
46
|
+
superviseTunnel,
|
|
47
|
+
type ReapDeps,
|
|
48
|
+
type Spawn,
|
|
49
|
+
type TunnelState,
|
|
50
|
+
} from "./supervise";
|
|
40
51
|
|
|
41
52
|
export interface Handlers {
|
|
42
53
|
onRequest: (req: IncomingMessage, res: ServerResponse) => void;
|
|
@@ -101,12 +112,51 @@ export interface ConnectDeps {
|
|
|
101
112
|
delays?: Parameters<typeof superviseTunnel>[0]["delays"];
|
|
102
113
|
heartbeat?: typeof sendHeartbeat;
|
|
103
114
|
heartbeatMs?: number;
|
|
115
|
+
reap?: ReapDeps;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether connections to 127.0.0.1:<port> reach `server`: one is made and
|
|
120
|
+
* must show up on it. Never rejects.
|
|
121
|
+
*/
|
|
122
|
+
export function listenerIsOurs(
|
|
123
|
+
server: http.Server,
|
|
124
|
+
port: number,
|
|
125
|
+
timeoutMs = 3000
|
|
126
|
+
): Promise<boolean> {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
const arrived = new Set<number>();
|
|
129
|
+
let probePort: number | undefined;
|
|
130
|
+
let done = false;
|
|
131
|
+
const finish = (ok: boolean) => {
|
|
132
|
+
if (done) return;
|
|
133
|
+
done = true;
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
server.removeListener("connection", onConnection);
|
|
136
|
+
client.destroy();
|
|
137
|
+
resolve(ok);
|
|
138
|
+
};
|
|
139
|
+
const onConnection = (s: Socket) => {
|
|
140
|
+
if (s.remotePort === undefined) return;
|
|
141
|
+
arrived.add(s.remotePort);
|
|
142
|
+
if (s.remotePort === probePort) finish(true);
|
|
143
|
+
};
|
|
144
|
+
server.prependListener("connection", onConnection);
|
|
145
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
146
|
+
timer.unref?.();
|
|
147
|
+
const client = net.connect(port, "127.0.0.1", () => {
|
|
148
|
+
probePort = client.localPort;
|
|
149
|
+
// The server may have seen it before this callback ran.
|
|
150
|
+
if (probePort !== undefined && arrived.has(probePort)) finish(true);
|
|
151
|
+
});
|
|
152
|
+
client.on("error", () => finish(false));
|
|
153
|
+
});
|
|
104
154
|
}
|
|
105
155
|
|
|
106
156
|
interface Running {
|
|
107
157
|
server: http.Server;
|
|
108
158
|
sockets: Set<Socket>;
|
|
109
|
-
supervisor: ReturnType<typeof superviseTunnel
|
|
159
|
+
supervisor: ReturnType<typeof superviseTunnel> | null;
|
|
110
160
|
heartbeat: ReturnType<typeof startHeartbeat>;
|
|
111
161
|
release: () => void;
|
|
112
162
|
}
|
|
@@ -131,12 +181,12 @@ export function startConnect(
|
|
|
131
181
|
let bindError: string | undefined;
|
|
132
182
|
g.__agentosConnect = () => {
|
|
133
183
|
if (!running) return base;
|
|
134
|
-
const s: TunnelState = running.supervisor
|
|
184
|
+
const s: TunnelState = running.supervisor?.state() ?? "connecting";
|
|
135
185
|
return {
|
|
136
186
|
...base,
|
|
137
187
|
hostname: hostname || base.hostname,
|
|
138
188
|
state: bindError ? "error" : s === "stopped" ? "off" : s,
|
|
139
|
-
reason: bindError ?? running.supervisor
|
|
189
|
+
reason: bindError ?? running.supervisor?.reason(),
|
|
140
190
|
};
|
|
141
191
|
};
|
|
142
192
|
|
|
@@ -153,7 +203,7 @@ export function startConnect(
|
|
|
153
203
|
if (!running) return;
|
|
154
204
|
bindError = undefined;
|
|
155
205
|
running.heartbeat.stop();
|
|
156
|
-
running.supervisor
|
|
206
|
+
running.supervisor?.stop();
|
|
157
207
|
running.sockets.forEach((s) => s.destroy());
|
|
158
208
|
running.server.close();
|
|
159
209
|
running.release();
|
|
@@ -200,19 +250,50 @@ export function startConnect(
|
|
|
200
250
|
log(`connect: ${base.reason}`);
|
|
201
251
|
return;
|
|
202
252
|
}
|
|
253
|
+
// The lock is ours, so a cloudflared in the pid file belongs to an
|
|
254
|
+
// AgentOS that died: it goes before anything else happens.
|
|
255
|
+
const reaped = reapTunnel(dir, undefined, deps.reap).catch(() => false);
|
|
203
256
|
const server = tunnelListener(() => hostname, handlers);
|
|
204
257
|
const sockets = new Set<Socket>();
|
|
205
258
|
server.on("connection", (s: Socket) => {
|
|
206
259
|
sockets.add(s);
|
|
207
260
|
s.once("close", () => sockets.delete(s));
|
|
208
261
|
});
|
|
262
|
+
const ensure =
|
|
263
|
+
deps.ensureBinary ?? ((d: string) => ensureCloudflared({ dir: d }));
|
|
264
|
+
const ask = deps.requestTunnel ?? requestTunnel;
|
|
265
|
+
const startTunnel = () =>
|
|
266
|
+
superviseTunnel({
|
|
267
|
+
dir,
|
|
268
|
+
log,
|
|
269
|
+
spawn: deps.spawn,
|
|
270
|
+
delays: deps.delays,
|
|
271
|
+
prepare: async () => {
|
|
272
|
+
const bin = await ensure(dir);
|
|
273
|
+
// Fresh every start; the token lives only in this call and the child's env.
|
|
274
|
+
const tunnel = await ask(config, machineKey, tunnelPort);
|
|
275
|
+
if (tunnel.hostname !== hostname) {
|
|
276
|
+
hostname = tunnel.hostname;
|
|
277
|
+
saveConnect({ ...config, hostname }, dir);
|
|
278
|
+
log(`connect: reachable at https://${hostname}`);
|
|
279
|
+
}
|
|
280
|
+
return { bin, token: tunnel.token };
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const stopTunnel = () => {
|
|
284
|
+
if (running?.server !== server || !running.supervisor) return;
|
|
285
|
+
running.supervisor.stop();
|
|
286
|
+
running.supervisor = null;
|
|
287
|
+
};
|
|
209
288
|
// Something else may hold the port for a while (an AgentOS shutting
|
|
210
|
-
// down, say): keep trying with backoff rather than giving up
|
|
289
|
+
// down, say): keep trying with backoff rather than giving up, with
|
|
290
|
+
// cloudflared stopped meanwhile.
|
|
211
291
|
const bindDelays = deps.delays ?? { min: 1000, max: 60_000 };
|
|
212
292
|
let bindDelay = bindDelays.min;
|
|
213
|
-
|
|
293
|
+
const rebind = (why: string) => {
|
|
214
294
|
if (running?.server !== server) return;
|
|
215
|
-
|
|
295
|
+
stopTunnel();
|
|
296
|
+
bindError = `can't listen on 127.0.0.1:${tunnelPort}: ${why}`;
|
|
216
297
|
log(`connect: ${bindError}; trying again`);
|
|
217
298
|
const t = setTimeout(() => {
|
|
218
299
|
if (running?.server !== server) return;
|
|
@@ -221,30 +302,19 @@ export function startConnect(
|
|
|
221
302
|
}, bindDelay);
|
|
222
303
|
t.unref?.();
|
|
223
304
|
bindDelay = Math.min(bindDelay * 2, bindDelays.max);
|
|
224
|
-
}
|
|
225
|
-
server.on("
|
|
305
|
+
};
|
|
306
|
+
server.on("error", (err) => rebind(err.message));
|
|
307
|
+
server.on("close", () => stopTunnel());
|
|
308
|
+
server.on("listening", async () => {
|
|
309
|
+
const [ours] = await Promise.all([
|
|
310
|
+
listenerIsOurs(server, tunnelPort),
|
|
311
|
+
reaped,
|
|
312
|
+
]);
|
|
313
|
+
if (running?.server !== server || !server.listening) return;
|
|
314
|
+
if (!ours) return rebind("connections there don't reach AgentOS");
|
|
226
315
|
bindError = undefined;
|
|
227
316
|
bindDelay = bindDelays.min;
|
|
228
|
-
|
|
229
|
-
const ensure =
|
|
230
|
-
deps.ensureBinary ?? ((d: string) => ensureCloudflared({ dir: d }));
|
|
231
|
-
const ask = deps.requestTunnel ?? requestTunnel;
|
|
232
|
-
const supervisor = superviseTunnel({
|
|
233
|
-
dir,
|
|
234
|
-
log,
|
|
235
|
-
spawn: deps.spawn,
|
|
236
|
-
delays: deps.delays,
|
|
237
|
-
prepare: async () => {
|
|
238
|
-
const bin = await ensure(dir);
|
|
239
|
-
// Fresh every start; the token lives only in this call and the child's env.
|
|
240
|
-
const tunnel = await ask(config, machineKey, tunnelPort);
|
|
241
|
-
if (tunnel.hostname !== hostname) {
|
|
242
|
-
hostname = tunnel.hostname;
|
|
243
|
-
saveConnect({ ...config, hostname }, dir);
|
|
244
|
-
log(`connect: reachable at https://${hostname}`);
|
|
245
|
-
}
|
|
246
|
-
return { bin, token: tunnel.token };
|
|
247
|
-
},
|
|
317
|
+
if (!running.supervisor) running.supervisor = startTunnel();
|
|
248
318
|
});
|
|
249
319
|
const beat = deps.heartbeat ?? sendHeartbeat;
|
|
250
320
|
const version = appVersion();
|
|
@@ -261,7 +331,13 @@ export function startConnect(
|
|
|
261
331
|
down("machine no longer linked");
|
|
262
332
|
},
|
|
263
333
|
});
|
|
264
|
-
running = {
|
|
334
|
+
running = {
|
|
335
|
+
server,
|
|
336
|
+
sockets,
|
|
337
|
+
supervisor: null,
|
|
338
|
+
heartbeat,
|
|
339
|
+
release: lock.release,
|
|
340
|
+
};
|
|
265
341
|
server.listen(tunnelPort, "127.0.0.1");
|
|
266
342
|
};
|
|
267
343
|
|