@saadnvd1/agent-os 0.3.697 → 0.3.698
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/app/api/peer/ping/route.ts +10 -0
- package/lib/__fixtures__/connect-transport.ts +93 -0
- package/lib/__fixtures__/peer-machine.ts +45 -0
- package/lib/__fixtures__/peer-rpc.ts +224 -0
- package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
- package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
- package/lib/db/types.ts +2 -0
- package/lib/hosts/index.ts +28 -7
- package/lib/hosts/peer-socket.ts +3 -1
- package/lib/hosts/read-capped-bytes.ts +23 -0
- package/lib/hosts/remote-api.ts +112 -41
- package/lib/peer/allowlist.ts +136 -0
- package/lib/peer/audit.ts +144 -0
- package/lib/peer/client.ts +188 -0
- package/lib/peer/envelope.ts +424 -0
- package/lib/peer/internal.ts +22 -0
- package/lib/peer/keys.ts +122 -0
- package/lib/peer/links.ts +96 -0
- package/lib/peer/nonce-store.ts +23 -0
- package/lib/peer/replay.ts +81 -0
- package/lib/peer/rpc-intake.ts +229 -0
- package/lib/peer/rpc-server.ts +351 -0
- package/lib/security/auth.ts +42 -5
- package/lib/security/gate.ts +35 -12
- package/lib/security/route-guard.ts +3 -1
- package/lib/session-artifacts/peer.ts +12 -1
- package/lib/tasks/contract/authorize.ts +4 -0
- package/lib/worktree-sweep/testTemp.ts +2 -0
- package/package.json +1 -1
- package/server.ts +18 -0
package/lib/security/auth.ts
CHANGED
|
@@ -10,20 +10,33 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { IncomingHttpHeaders } from "http";
|
|
13
|
+
import { peerTargetAllowed } from "../peer/allowlist";
|
|
14
|
+
import { secretMatches } from "../peer/internal";
|
|
13
15
|
|
|
14
16
|
export const DEVICE_COOKIE = "aos_device";
|
|
15
17
|
// Set by server.ts on every request after stripping any client-sent copy.
|
|
16
18
|
export const DEVICE_HEADER = "x-agentos-device";
|
|
17
19
|
export const TRUST_HEADER = "x-agentos-trust";
|
|
18
20
|
export const REMOTE_HEADER = "x-agentos-remote";
|
|
21
|
+
// The rpc server's loopback hop (lib/peer/rpc-server): the per-boot secret,
|
|
22
|
+
// and the linked machine it verified. Never trusted from anywhere else.
|
|
23
|
+
export const PEER_INTERNAL_HEADER = "x-agentos-peer-internal";
|
|
24
|
+
export const PEER_HEADER = "x-agentos-peer";
|
|
19
25
|
|
|
20
|
-
|
|
26
|
+
/**
|
|
27
|
+
* `peer`: a linked machine's verified call, re-issued by this machine. It is
|
|
28
|
+
* never local trust and never an approver, and only reaches its allowlist.
|
|
29
|
+
*/
|
|
30
|
+
export type Trust = "loopback" | "tailnet" | "device" | "open" | "peer";
|
|
21
31
|
|
|
22
32
|
export type AuthResult =
|
|
23
|
-
{ ok: true; via: Trust; deviceId?: string
|
|
33
|
+
| { ok: true; via: Trust; deviceId?: string; peerHost?: string }
|
|
34
|
+
// hard: refused outright, even on a path that is otherwise public.
|
|
35
|
+
| { ok: false; hard?: true };
|
|
24
36
|
|
|
25
37
|
export interface AuthRequest {
|
|
26
38
|
url?: string;
|
|
39
|
+
method?: string;
|
|
27
40
|
headers: IncomingHttpHeaders;
|
|
28
41
|
remoteAddress?: string;
|
|
29
42
|
localAddress?: string;
|
|
@@ -47,11 +60,14 @@ export interface AuthPolicy {
|
|
|
47
60
|
/** AGENTOS_AUTH=off: someone else's proxy does the login. */
|
|
48
61
|
off?: boolean;
|
|
49
62
|
lookup: (token: string) => { id: string } | null;
|
|
63
|
+
/** The rpc server's per-boot secret; without it no request is a peer. */
|
|
64
|
+
peerSecret?: () => string;
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
const TAILSCALE_V4 = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./;
|
|
53
68
|
const LOOPBACK = new Set(["127.0.0.1", "::1"]);
|
|
54
69
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
70
|
+
const PEER_HOST_ID = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
55
71
|
// A proxy must send one of these, or it looks like this machine itself.
|
|
56
72
|
// Proxies that send none need AGENTOS_AUTH, see docs/reference/security.md.
|
|
57
73
|
const FORWARDING = new Set([
|
|
@@ -64,12 +80,13 @@ const FORWARDING = new Set([
|
|
|
64
80
|
]);
|
|
65
81
|
const FORWARDING_PREFIXES = ["x-forwarded-", "cf-", "tailscale-"];
|
|
66
82
|
|
|
67
|
-
// Reached without a token: the pairing page, what it needs to render,
|
|
68
|
-
//
|
|
83
|
+
// Reached without a token: the pairing page, what it needs to render, the
|
|
84
|
+
// claim it posts, and a linked machine's rpc (its envelope is the proof). Exact paths, plus a few static asset folders whose
|
|
69
85
|
// paths may only use plain characters (no traversal, no encoding).
|
|
70
86
|
const PUBLIC_EXACT = new Set([
|
|
71
87
|
"/pair",
|
|
72
88
|
"/api/pair/claim",
|
|
89
|
+
"/api/peer/rpc",
|
|
73
90
|
"/manifest.webmanifest",
|
|
74
91
|
"/manifest.json",
|
|
75
92
|
"/favicon.ico",
|
|
@@ -155,11 +172,31 @@ export function authorize(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
|
155
172
|
try {
|
|
156
173
|
return decide(req, policy);
|
|
157
174
|
} catch {
|
|
158
|
-
return
|
|
175
|
+
return req.headers[PEER_INTERNAL_HEADER] !== undefined
|
|
176
|
+
? { ok: false, hard: true }
|
|
177
|
+
: { ok: false };
|
|
159
178
|
}
|
|
160
179
|
}
|
|
161
180
|
|
|
181
|
+
/**
|
|
182
|
+
* A request carrying the internal header is a peer call or nothing: it
|
|
183
|
+
* never falls back to loopback trust, whatever its address says.
|
|
184
|
+
*/
|
|
185
|
+
function peerTrust(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
186
|
+
const refuse = { ok: false, hard: true } as const;
|
|
187
|
+
if (req.tunnel || !policy.peerSecret || proxied(req.headers)) return refuse;
|
|
188
|
+
if (!LOOPBACK.has(plainAddress(req.remoteAddress))) return refuse;
|
|
189
|
+
if (!secretMatches(req.headers[PEER_INTERNAL_HEADER], policy.peerSecret()))
|
|
190
|
+
return refuse;
|
|
191
|
+
const host = req.headers[PEER_HEADER];
|
|
192
|
+
if (typeof host !== "string" || !PEER_HOST_ID.test(host)) return refuse;
|
|
193
|
+
if (!peerTargetAllowed(req.method, req.url)) return refuse;
|
|
194
|
+
return { ok: true, via: "peer", deviceId: `peer:${host}`, peerHost: host };
|
|
195
|
+
}
|
|
196
|
+
|
|
162
197
|
function decide(req: AuthRequest, policy: AuthPolicy): AuthResult {
|
|
198
|
+
if (req.headers[PEER_INTERNAL_HEADER] !== undefined)
|
|
199
|
+
return peerTrust(req, policy);
|
|
163
200
|
const trusted = req.tunnel ? null : networkTrust(req, policy);
|
|
164
201
|
const token = readToken(req.headers);
|
|
165
202
|
if (trusted) {
|
package/lib/security/gate.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
DEVICE_HEADER,
|
|
15
15
|
TRUST_HEADER,
|
|
16
16
|
REMOTE_HEADER,
|
|
17
|
+
PEER_HEADER,
|
|
18
|
+
PEER_INTERNAL_HEADER,
|
|
17
19
|
type AuthPolicy,
|
|
18
20
|
type AuthResult,
|
|
19
21
|
} from "./auth";
|
|
@@ -22,6 +24,7 @@ import { deviceForToken, touchDevice, trackDeviceSocket } from "./devices";
|
|
|
22
24
|
import { tailscaleAddresses } from "./net";
|
|
23
25
|
import { networkSetting } from "./network-settings";
|
|
24
26
|
import { pairLocation } from "../session-url";
|
|
27
|
+
import { peerSecret } from "../peer/internal";
|
|
25
28
|
|
|
26
29
|
// Interface addresses change rarely; don't re-read them on every request.
|
|
27
30
|
let tailnetCache = { at: 0, ips: [] as string[] };
|
|
@@ -42,6 +45,7 @@ export function authPolicy(env = process.env): AuthPolicy {
|
|
|
42
45
|
},
|
|
43
46
|
off: env.AGENTOS_AUTH === "off",
|
|
44
47
|
lookup: deviceForToken,
|
|
48
|
+
peerSecret,
|
|
45
49
|
};
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -54,21 +58,36 @@ function decide(req: IncomingMessage, policy: AuthPolicy): AuthResult {
|
|
|
54
58
|
// Never trust a client-supplied verdict.
|
|
55
59
|
delete req.headers[DEVICE_HEADER];
|
|
56
60
|
delete req.headers[TRUST_HEADER];
|
|
61
|
+
// A peer's identity only ever comes with the internal secret.
|
|
62
|
+
if (req.headers[PEER_INTERNAL_HEADER] === undefined)
|
|
63
|
+
delete req.headers[PEER_HEADER];
|
|
57
64
|
const tunnel = isTunnelSocket(req.socket);
|
|
58
65
|
req.headers[REMOTE_HEADER] = sourceLabel(req.socket.remoteAddress, tunnel);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
let result: AuthResult;
|
|
67
|
+
try {
|
|
68
|
+
result = authorize(
|
|
69
|
+
{
|
|
70
|
+
url: req.url,
|
|
71
|
+
method: req.method,
|
|
72
|
+
headers: req.headers,
|
|
73
|
+
remoteAddress: req.socket.remoteAddress,
|
|
74
|
+
localAddress: req.socket.localAddress,
|
|
75
|
+
tunnel,
|
|
76
|
+
},
|
|
77
|
+
policy
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
// No route ever sees the secret.
|
|
81
|
+
delete req.headers[PEER_INTERNAL_HEADER];
|
|
82
|
+
}
|
|
83
|
+
delete req.headers[PEER_HEADER];
|
|
69
84
|
if (result.ok) {
|
|
70
85
|
req.headers[TRUST_HEADER] = result.via;
|
|
71
86
|
if (result.deviceId) req.headers[DEVICE_HEADER] = result.deviceId;
|
|
87
|
+
if (result.peerHost) {
|
|
88
|
+
req.headers[PEER_HEADER] = result.peerHost;
|
|
89
|
+
req.headers[REMOTE_HEADER] = "peer";
|
|
90
|
+
}
|
|
72
91
|
}
|
|
73
92
|
return result;
|
|
74
93
|
}
|
|
@@ -81,6 +100,8 @@ function check(req: IncomingMessage, policy: AuthPolicy): AuthResult {
|
|
|
81
100
|
console.error("access check failed:", err);
|
|
82
101
|
delete req.headers[TRUST_HEADER];
|
|
83
102
|
delete req.headers[DEVICE_HEADER];
|
|
103
|
+
delete req.headers[PEER_HEADER];
|
|
104
|
+
delete req.headers[PEER_INTERNAL_HEADER];
|
|
84
105
|
return { ok: false };
|
|
85
106
|
}
|
|
86
107
|
}
|
|
@@ -102,6 +123,7 @@ export function gateRequest(
|
|
|
102
123
|
): boolean {
|
|
103
124
|
const result = check(req, policy);
|
|
104
125
|
if (result.ok) {
|
|
126
|
+
if (result.via === "peer") return true;
|
|
105
127
|
// Slide the cookie's expiry forward, at most once a minute per device.
|
|
106
128
|
const cookie = result.deviceId ? cookieToken(req.headers) : null;
|
|
107
129
|
if (result.deviceId && touch(req, result.deviceId) && cookie) {
|
|
@@ -114,7 +136,7 @@ export function gateRequest(
|
|
|
114
136
|
}
|
|
115
137
|
return true;
|
|
116
138
|
}
|
|
117
|
-
if (isPublicPath(req.url)) return true;
|
|
139
|
+
if (!result.hard && isPublicPath(req.url)) return true;
|
|
118
140
|
const path = (req.url ?? "/").split("?")[0];
|
|
119
141
|
const wantsPage =
|
|
120
142
|
!path.startsWith("/api/") &&
|
|
@@ -139,7 +161,8 @@ export function gateUpgrade(
|
|
|
139
161
|
policy: AuthPolicy
|
|
140
162
|
): boolean {
|
|
141
163
|
const result = check(req, policy);
|
|
142
|
-
|
|
164
|
+
// A peer reaches its allowlisted routes and never a socket.
|
|
165
|
+
if (!result.ok || result.via === "peer") {
|
|
143
166
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
144
167
|
socket.destroy();
|
|
145
168
|
return false;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* For actions a stolen device token must not be able to take (adding
|
|
3
|
-
* devices, opening up the network): only this machine or the tailnet.
|
|
3
|
+
* devices, opening up the network): only this machine or the tailnet. A
|
|
4
|
+
* linked machine's call (`peer`) never passes, even though it arrives on
|
|
5
|
+
* loopback.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { NextResponse, type NextRequest } from "next/server";
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
import { ARTIFACT_HEADERS } from "../artifacts/serve";
|
|
9
9
|
import { IMAGE_TYPES } from "../artifacts/image-paths";
|
|
10
10
|
import { IMAGE_CSP } from "../artifacts/serve";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
cleanRemoteText,
|
|
13
|
+
hostApi,
|
|
14
|
+
isConnectLink,
|
|
15
|
+
type HostLink,
|
|
16
|
+
} from "../hosts/remote-api";
|
|
12
17
|
import type {
|
|
13
18
|
ArtifactKind,
|
|
14
19
|
ArtifactPreview,
|
|
@@ -148,6 +153,12 @@ export async function peerContent(
|
|
|
148
153
|
): Promise<Content> {
|
|
149
154
|
if (!ID.test(artifactId))
|
|
150
155
|
return { ok: false, status: 404, error: "Not found" };
|
|
156
|
+
if (isConnectLink(link))
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
status: 409,
|
|
160
|
+
error: `${link.hostName} is linked through Connect: artifacts need a Tailscale/ssh link for now`,
|
|
161
|
+
};
|
|
151
162
|
let res: Response;
|
|
152
163
|
try {
|
|
153
164
|
res = await fetch(
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* (which sends no device) is never refused on them.
|
|
10
10
|
* - plan: open, and a plan another machine placed here is read and approved
|
|
11
11
|
* by that machine only.
|
|
12
|
+
*
|
|
13
|
+
* The caller is the gate's device id. A machine linked through Connect is
|
|
14
|
+
* `peer:<host id>` (lib/security/gate.ts), with no Can approve: the same
|
|
15
|
+
* standing a linked machine's device token has.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import { NextRequest, NextResponse } from "next/server";
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
refuseNextInternal,
|
|
48
48
|
} from "./lib/security/next-internals";
|
|
49
49
|
import { lanEnabled } from "./lib/security/network-settings";
|
|
50
|
+
import { handlePeerRpc, isPeerRpc, rpcDeps } from "./lib/peer/rpc-server";
|
|
50
51
|
import { startConnect } from "./lib/connect/serve";
|
|
51
52
|
import { serverPort } from "./lib/connect/config";
|
|
52
53
|
import { startUpdateChecks, updateChecksEnabled } from "./lib/updates/check";
|
|
@@ -150,6 +151,7 @@ app.prepare().then(async () => {
|
|
|
150
151
|
.map((h) => h.trim().toLowerCase())
|
|
151
152
|
.filter(Boolean);
|
|
152
153
|
const auth = authPolicy();
|
|
154
|
+
const peerRpc = rpcDeps(port);
|
|
153
155
|
const header = (v: string | string[] | undefined) =>
|
|
154
156
|
Array.isArray(v) ? v[0] : v;
|
|
155
157
|
|
|
@@ -177,6 +179,22 @@ app.prepare().then(async () => {
|
|
|
177
179
|
if (refuseNextInternal(req, res)) return;
|
|
178
180
|
if (!gateRequest(req, res, auth)) return;
|
|
179
181
|
if (demo && !gateDemoRequest(req, res)) return;
|
|
182
|
+
// A linked machine's envelope: verified, then re-issued to loopback.
|
|
183
|
+
if (isPeerRpc(req)) {
|
|
184
|
+
if (demo) {
|
|
185
|
+
res.statusCode = 404;
|
|
186
|
+
res.end();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
await handlePeerRpc(req, res, peerRpc);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
console.error("peer rpc failed:", err);
|
|
193
|
+
if (!res.headersSent) res.statusCode = 500;
|
|
194
|
+
res.end();
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
180
198
|
try {
|
|
181
199
|
await compressJson(req, res);
|
|
182
200
|
const parsedUrl = parse(req.url!, true);
|