@linkshell/gateway 0.2.47 → 0.2.48
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/dist/gateway/src/agent-permission-http.d.ts +18 -9
- package/dist/gateway/src/agent-permission-http.js +18 -10
- package/dist/gateway/src/agent-permission-http.js.map +1 -1
- package/dist/gateway/src/embedded.js +119 -55
- package/dist/gateway/src/embedded.js.map +1 -1
- package/dist/gateway/src/index.js +158 -91
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/pairings.d.ts +3 -3
- package/dist/gateway/src/pairings.js +4 -5
- package/dist/gateway/src/pairings.js.map +1 -1
- package/dist/gateway/src/relay.d.ts +1 -1
- package/dist/gateway/src/relay.js +23 -18
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/sessions.d.ts +35 -28
- package/dist/gateway/src/sessions.js +165 -145
- package/dist/gateway/src/sessions.js.map +1 -1
- package/dist/gateway/src/state-store.d.ts +9 -6
- package/dist/gateway/src/state-store.js +26 -19
- package/dist/gateway/src/state-store.js.map +1 -1
- package/dist/gateway/src/tokens.d.ts +27 -7
- package/dist/gateway/src/tokens.js +86 -60
- package/dist/gateway/src/tokens.js.map +1 -1
- package/dist/gateway/src/tunnel.d.ts +11 -10
- package/dist/gateway/src/tunnel.js +46 -35
- package/dist/gateway/src/tunnel.js.map +1 -1
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +271 -223
- package/dist/shared-protocol/src/index.js +31 -15
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +1 -1
- package/src/agent-permission-http.ts +18 -10
- package/src/embedded.ts +122 -54
- package/src/index.ts +162 -91
- package/src/pairings.ts +6 -7
- package/src/relay.ts +26 -20
- package/src/sessions.ts +179 -150
- package/src/state-store.ts +41 -25
- package/src/tokens.ts +109 -63
- package/src/tunnel.ts +57 -39
package/src/state-store.ts
CHANGED
|
@@ -1,28 +1,35 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface StoredAuthorizationRecord {
|
|
2
|
+
authorizationId: string;
|
|
2
3
|
token: string;
|
|
3
|
-
|
|
4
|
+
hostDeviceId: string;
|
|
5
|
+
clientDeviceId?: string;
|
|
6
|
+
clientName?: string;
|
|
4
7
|
createdAt: number;
|
|
5
8
|
lastUsedAt: number;
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
export interface StoredPairingRecord {
|
|
9
|
-
|
|
12
|
+
hostDeviceId: string;
|
|
10
13
|
pairingCode: string;
|
|
11
14
|
expiresAt: number;
|
|
12
15
|
claimed: boolean;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export interface GatewayStateStore {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
loadAuthorizations(): Promise<StoredAuthorizationRecord[]>;
|
|
20
|
+
saveAuthorization(record: StoredAuthorizationRecord): Promise<void>;
|
|
21
|
+
deleteAuthorization(authorizationId: string): Promise<void>;
|
|
19
22
|
loadPairings(): Promise<StoredPairingRecord[]>;
|
|
20
23
|
savePairing(record: StoredPairingRecord): Promise<void>;
|
|
21
24
|
deletePairing(pairingCode: string): Promise<void>;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
const
|
|
25
|
-
|
|
27
|
+
const AUTHORIZATION_TABLE =
|
|
28
|
+
process.env.SUPABASE_GATEWAY_AUTHORIZATION_TABLE ??
|
|
29
|
+
"linkshell_gateway_device_authorizations";
|
|
30
|
+
const PAIRING_TABLE =
|
|
31
|
+
process.env.SUPABASE_GATEWAY_PAIRING_TABLE ??
|
|
32
|
+
"linkshell_gateway_pairing_challenges";
|
|
26
33
|
const STORE_TIMEOUT_MS = Number(process.env.SUPABASE_STATE_TIMEOUT_MS ?? 3_000);
|
|
27
34
|
|
|
28
35
|
function msToIso(ms: number): string {
|
|
@@ -35,6 +42,10 @@ function isoToMs(value: unknown): number {
|
|
|
35
42
|
return Number.isNaN(parsed) ? Date.now() : parsed;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
function maybeString(value: unknown): string | undefined {
|
|
46
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
export function createSupabaseStateStore(): GatewayStateStore | undefined {
|
|
39
50
|
const url = process.env.SUPABASE_URL?.replace(/\/+$/, "");
|
|
40
51
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
|
|
@@ -64,49 +75,54 @@ export function createSupabaseStateStore(): GatewayStateStore | undefined {
|
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
return {
|
|
67
|
-
async
|
|
78
|
+
async loadAuthorizations() {
|
|
68
79
|
const rows = await request<Array<Record<string, unknown>>>(
|
|
69
|
-
`${
|
|
80
|
+
`${AUTHORIZATION_TABLE}?select=authorization_id,token,host_device_id,client_device_id,client_name,created_at,last_used_at`,
|
|
70
81
|
);
|
|
71
82
|
return rows.map((row) => ({
|
|
83
|
+
authorizationId: String(row.authorization_id ?? ""),
|
|
72
84
|
token: String(row.token ?? ""),
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
hostDeviceId: String(row.host_device_id ?? ""),
|
|
86
|
+
clientDeviceId: maybeString(row.client_device_id),
|
|
87
|
+
clientName: maybeString(row.client_name),
|
|
76
88
|
createdAt: isoToMs(row.created_at),
|
|
77
89
|
lastUsedAt: isoToMs(row.last_used_at),
|
|
78
|
-
})).filter((record) => record.token);
|
|
90
|
+
})).filter((record) => record.authorizationId && record.token && record.hostDeviceId);
|
|
79
91
|
},
|
|
80
|
-
async
|
|
92
|
+
async saveAuthorization(record) {
|
|
81
93
|
await request(
|
|
82
|
-
`${
|
|
94
|
+
`${AUTHORIZATION_TABLE}?on_conflict=authorization_id`,
|
|
83
95
|
{
|
|
84
96
|
method: "POST",
|
|
85
97
|
headers: { Prefer: "resolution=merge-duplicates" },
|
|
86
98
|
body: JSON.stringify({
|
|
99
|
+
authorization_id: record.authorizationId,
|
|
87
100
|
token: record.token,
|
|
88
|
-
|
|
101
|
+
host_device_id: record.hostDeviceId,
|
|
102
|
+
client_device_id: record.clientDeviceId ?? null,
|
|
103
|
+
client_name: record.clientName ?? null,
|
|
89
104
|
created_at: msToIso(record.createdAt),
|
|
90
105
|
last_used_at: msToIso(record.lastUsedAt),
|
|
91
106
|
}),
|
|
92
107
|
},
|
|
93
108
|
);
|
|
94
109
|
},
|
|
95
|
-
async
|
|
96
|
-
await request(
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
async deleteAuthorization(authorizationId) {
|
|
111
|
+
await request(
|
|
112
|
+
`${AUTHORIZATION_TABLE}?authorization_id=eq.${encodeURIComponent(authorizationId)}`,
|
|
113
|
+
{ method: "DELETE" },
|
|
114
|
+
);
|
|
99
115
|
},
|
|
100
116
|
async loadPairings() {
|
|
101
117
|
const rows = await request<Array<Record<string, unknown>>>(
|
|
102
|
-
`${PAIRING_TABLE}?select=pairing_code,
|
|
118
|
+
`${PAIRING_TABLE}?select=pairing_code,host_device_id,expires_at,claimed`,
|
|
103
119
|
);
|
|
104
120
|
return rows.map((row) => ({
|
|
105
121
|
pairingCode: String(row.pairing_code ?? ""),
|
|
106
|
-
|
|
122
|
+
hostDeviceId: String(row.host_device_id ?? ""),
|
|
107
123
|
expiresAt: isoToMs(row.expires_at),
|
|
108
124
|
claimed: row.claimed === true,
|
|
109
|
-
})).filter((record) => record.pairingCode && record.
|
|
125
|
+
})).filter((record) => record.pairingCode && record.hostDeviceId);
|
|
110
126
|
},
|
|
111
127
|
async savePairing(record) {
|
|
112
128
|
await request(
|
|
@@ -116,7 +132,7 @@ export function createSupabaseStateStore(): GatewayStateStore | undefined {
|
|
|
116
132
|
headers: { Prefer: "resolution=merge-duplicates" },
|
|
117
133
|
body: JSON.stringify({
|
|
118
134
|
pairing_code: record.pairingCode,
|
|
119
|
-
|
|
135
|
+
host_device_id: record.hostDeviceId,
|
|
120
136
|
expires_at: msToIso(record.expiresAt),
|
|
121
137
|
claimed: record.claimed,
|
|
122
138
|
}),
|
package/src/tokens.ts
CHANGED
|
@@ -1,47 +1,45 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { GatewayStateStore } from "./state-store.js";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
interface DeviceAuthorization {
|
|
5
|
+
authorizationId: string;
|
|
6
|
+
hostDeviceId: string;
|
|
7
|
+
clientDeviceId: string | undefined;
|
|
8
|
+
clientName: string | undefined;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
lastUsedAt: number;
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
interface TokenRecord {
|
|
8
14
|
token: string;
|
|
9
|
-
|
|
15
|
+
authorizations: Map<string, DeviceAuthorization>;
|
|
10
16
|
createdAt: number;
|
|
11
17
|
lastUsedAt: number;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export class
|
|
20
|
+
export class AuthorizationManager {
|
|
15
21
|
private tokens = new Map<string, TokenRecord>();
|
|
16
|
-
private
|
|
17
|
-
private cleanupTimer: ReturnType<typeof setInterval>;
|
|
22
|
+
private hostDeviceToTokens = new Map<string, Set<string>>();
|
|
18
23
|
|
|
19
|
-
constructor(private readonly store?: GatewayStateStore) {
|
|
20
|
-
this.cleanupTimer = setInterval(() => this.cleanup(), CLEANUP_INTERVAL);
|
|
21
|
-
}
|
|
24
|
+
constructor(private readonly store?: GatewayStateStore) {}
|
|
22
25
|
|
|
23
26
|
async hydrate(): Promise<void> {
|
|
24
27
|
if (!this.store) return;
|
|
25
28
|
try {
|
|
26
|
-
const records = await this.store.
|
|
27
|
-
const now = Date.now();
|
|
29
|
+
const records = await this.store.loadAuthorizations();
|
|
28
30
|
for (const record of records) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
token: record.token,
|
|
35
|
-
sessionIds: new Set(record.sessionIds),
|
|
31
|
+
const token = this.register(record.token);
|
|
32
|
+
this.authorize(token, record.hostDeviceId, {
|
|
33
|
+
authorizationId: record.authorizationId,
|
|
34
|
+
clientDeviceId: record.clientDeviceId,
|
|
35
|
+
clientName: record.clientName,
|
|
36
36
|
createdAt: record.createdAt,
|
|
37
37
|
lastUsedAt: record.lastUsedAt,
|
|
38
|
+
persist: false,
|
|
38
39
|
});
|
|
39
|
-
for (const sessionId of record.sessionIds) {
|
|
40
|
-
this.sessionToToken.set(sessionId, record.token);
|
|
41
|
-
}
|
|
42
40
|
}
|
|
43
41
|
} catch (err) {
|
|
44
|
-
process.stderr.write(`[gateway]
|
|
42
|
+
process.stderr.write(`[gateway] authorization store hydrate failed, using memory only: ${err}\n`);
|
|
45
43
|
}
|
|
46
44
|
}
|
|
47
45
|
|
|
@@ -49,83 +47,131 @@ export class TokenManager {
|
|
|
49
47
|
if (deviceToken && this.tokens.has(deviceToken)) {
|
|
50
48
|
const record = this.tokens.get(deviceToken)!;
|
|
51
49
|
record.lastUsedAt = Date.now();
|
|
52
|
-
this.persist(record);
|
|
53
50
|
return deviceToken;
|
|
54
51
|
}
|
|
55
52
|
const token = deviceToken || randomUUID();
|
|
56
53
|
this.tokens.set(token, {
|
|
57
54
|
token,
|
|
58
|
-
|
|
55
|
+
authorizations: new Map(),
|
|
59
56
|
createdAt: Date.now(),
|
|
60
57
|
lastUsedAt: Date.now(),
|
|
61
58
|
});
|
|
62
|
-
this.persist(this.tokens.get(token)!);
|
|
63
59
|
return token;
|
|
64
60
|
}
|
|
65
61
|
|
|
66
|
-
|
|
62
|
+
authorize(
|
|
63
|
+
token: string,
|
|
64
|
+
hostDeviceId: string,
|
|
65
|
+
input: {
|
|
66
|
+
authorizationId?: string;
|
|
67
|
+
clientDeviceId?: string;
|
|
68
|
+
clientName?: string;
|
|
69
|
+
createdAt?: number;
|
|
70
|
+
lastUsedAt?: number;
|
|
71
|
+
persist?: boolean;
|
|
72
|
+
} = {},
|
|
73
|
+
): DeviceAuthorization | undefined {
|
|
67
74
|
const record = this.tokens.get(token);
|
|
68
|
-
if (!record) return
|
|
69
|
-
record.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
if (!record) return undefined;
|
|
76
|
+
const existing = record.authorizations.get(hostDeviceId);
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const authorization: DeviceAuthorization = {
|
|
79
|
+
authorizationId: input.authorizationId ?? existing?.authorizationId ?? randomUUID(),
|
|
80
|
+
hostDeviceId,
|
|
81
|
+
clientDeviceId: input.clientDeviceId ?? existing?.clientDeviceId,
|
|
82
|
+
clientName: input.clientName ?? existing?.clientName,
|
|
83
|
+
createdAt: input.createdAt ?? existing?.createdAt ?? now,
|
|
84
|
+
lastUsedAt: input.lastUsedAt ?? now,
|
|
85
|
+
};
|
|
86
|
+
record.authorizations.set(hostDeviceId, authorization);
|
|
87
|
+
record.lastUsedAt = now;
|
|
88
|
+
let tokens = this.hostDeviceToTokens.get(hostDeviceId);
|
|
89
|
+
if (!tokens) {
|
|
90
|
+
tokens = new Set();
|
|
91
|
+
this.hostDeviceToTokens.set(hostDeviceId, tokens);
|
|
92
|
+
}
|
|
93
|
+
tokens.add(token);
|
|
94
|
+
if (input.persist !== false) {
|
|
95
|
+
this.persist(token, authorization);
|
|
96
|
+
}
|
|
97
|
+
return authorization;
|
|
74
98
|
}
|
|
75
99
|
|
|
76
100
|
validate(token: string): boolean {
|
|
77
101
|
const record = this.tokens.get(token);
|
|
78
102
|
if (!record) return false;
|
|
79
103
|
record.lastUsedAt = Date.now();
|
|
80
|
-
this.persist(record);
|
|
81
104
|
return true;
|
|
82
105
|
}
|
|
83
106
|
|
|
84
|
-
owns(token: string,
|
|
107
|
+
owns(token: string, hostDeviceId: string): boolean {
|
|
85
108
|
const record = this.tokens.get(token);
|
|
86
109
|
if (!record) return false;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
const authorization = record.authorizations.get(hostDeviceId);
|
|
111
|
+
if (!authorization) return false;
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
record.lastUsedAt = now;
|
|
114
|
+
authorization.lastUsedAt = now;
|
|
115
|
+
this.persist(token, authorization);
|
|
116
|
+
return true;
|
|
90
117
|
}
|
|
91
118
|
|
|
92
|
-
|
|
119
|
+
revoke(token: string, hostDeviceId: string, authorizationId: string): boolean {
|
|
120
|
+
const record = this.tokens.get(token);
|
|
121
|
+
const authorization = record?.authorizations.get(hostDeviceId);
|
|
122
|
+
if (!record || !authorization || authorization.authorizationId !== authorizationId) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
record.authorizations.delete(hostDeviceId);
|
|
126
|
+
const tokens = this.hostDeviceToTokens.get(hostDeviceId);
|
|
127
|
+
tokens?.delete(token);
|
|
128
|
+
if (tokens && tokens.size === 0) {
|
|
129
|
+
this.hostDeviceToTokens.delete(hostDeviceId);
|
|
130
|
+
}
|
|
131
|
+
void this.store?.deleteAuthorization(authorizationId).catch((err) => {
|
|
132
|
+
process.stderr.write(`[gateway] authorization store delete failed: ${err}\n`);
|
|
133
|
+
});
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
getHostDeviceIds(token: string): Set<string> {
|
|
93
138
|
const record = this.tokens.get(token);
|
|
94
139
|
if (!record) return new Set();
|
|
95
140
|
record.lastUsedAt = Date.now();
|
|
96
|
-
|
|
97
|
-
return record.sessionIds;
|
|
141
|
+
return new Set(record.authorizations.keys());
|
|
98
142
|
}
|
|
99
143
|
|
|
100
|
-
|
|
101
|
-
return this.
|
|
144
|
+
getSessionIds(token: string): Set<string> {
|
|
145
|
+
return this.getHostDeviceIds(token);
|
|
102
146
|
}
|
|
103
147
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
148
|
+
getAuthorizationId(token: string, hostDeviceId: string): string | undefined {
|
|
149
|
+
return this.tokens.get(token)?.authorizations.get(hostDeviceId)?.authorizationId;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getTokenForSession(hostDeviceId: string): string | undefined {
|
|
153
|
+
return this.hostDeviceToTokens.get(hostDeviceId)?.values().next().value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
bind(token: string, hostDeviceId: string): boolean {
|
|
157
|
+
return !!this.authorize(token, hostDeviceId);
|
|
115
158
|
}
|
|
116
159
|
|
|
117
|
-
private persist(
|
|
118
|
-
void this.store?.
|
|
119
|
-
token
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
160
|
+
private persist(token: string, authorization: DeviceAuthorization): void {
|
|
161
|
+
void this.store?.saveAuthorization({
|
|
162
|
+
token,
|
|
163
|
+
authorizationId: authorization.authorizationId,
|
|
164
|
+
hostDeviceId: authorization.hostDeviceId,
|
|
165
|
+
clientDeviceId: authorization.clientDeviceId,
|
|
166
|
+
clientName: authorization.clientName,
|
|
167
|
+
createdAt: authorization.createdAt,
|
|
168
|
+
lastUsedAt: authorization.lastUsedAt,
|
|
123
169
|
}).catch((err) => {
|
|
124
|
-
process.stderr.write(`[gateway]
|
|
170
|
+
process.stderr.write(`[gateway] authorization store save failed: ${err}\n`);
|
|
125
171
|
});
|
|
126
172
|
}
|
|
127
173
|
|
|
128
|
-
destroy(): void {
|
|
129
|
-
clearInterval(this.cleanupTimer);
|
|
130
|
-
}
|
|
174
|
+
destroy(): void {}
|
|
131
175
|
}
|
|
176
|
+
|
|
177
|
+
export class TokenManager extends AuthorizationManager {}
|
package/src/tunnel.ts
CHANGED
|
@@ -26,23 +26,23 @@ export interface PendingTunnelWs {
|
|
|
26
26
|
const pendingRequests = new Map<string, PendingTunnelRequest>();
|
|
27
27
|
const pendingWsSockets = new Map<string, PendingTunnelWs>();
|
|
28
28
|
|
|
29
|
-
// Track requestIds per
|
|
30
|
-
const
|
|
29
|
+
// Track requestIds per host device for cleanup on host disconnect
|
|
30
|
+
const deviceRequests = new Map<string, Set<string>>();
|
|
31
31
|
|
|
32
|
-
function trackRequest(
|
|
33
|
-
let set =
|
|
32
|
+
function trackRequest(hostDeviceId: string, requestId: string): void {
|
|
33
|
+
let set = deviceRequests.get(hostDeviceId);
|
|
34
34
|
if (!set) {
|
|
35
35
|
set = new Set();
|
|
36
|
-
|
|
36
|
+
deviceRequests.set(hostDeviceId, set);
|
|
37
37
|
}
|
|
38
38
|
set.add(requestId);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function untrackRequest(
|
|
42
|
-
const set =
|
|
41
|
+
function untrackRequest(hostDeviceId: string, requestId: string): void {
|
|
42
|
+
const set = deviceRequests.get(hostDeviceId);
|
|
43
43
|
if (set) {
|
|
44
44
|
set.delete(requestId);
|
|
45
|
-
if (set.size === 0)
|
|
45
|
+
if (set.size === 0) deviceRequests.delete(hostDeviceId);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -65,19 +65,19 @@ function extractToken(req: IncomingMessage, url: URL): string | null {
|
|
|
65
65
|
return null;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/** Parse lsh_tunnel cookie: "
|
|
69
|
-
export function parseTunnelCookie(req: IncomingMessage): { sessionId: string; port: number; token: string } | null {
|
|
68
|
+
/** Parse lsh_tunnel cookie: "hostDeviceId:port:token" */
|
|
69
|
+
export function parseTunnelCookie(req: IncomingMessage): { hostDeviceId: string; sessionId: string; port: number; token: string } | null {
|
|
70
70
|
const cookie = req.headers.cookie;
|
|
71
71
|
if (!cookie) return null;
|
|
72
72
|
const match = cookie.match(/lsh_tunnel=([^;]+)/);
|
|
73
73
|
if (!match?.[1]) return null;
|
|
74
74
|
const parts = decodeURIComponent(match[1]).split(":");
|
|
75
75
|
if (parts.length < 3) return null;
|
|
76
|
-
const
|
|
76
|
+
const hostDeviceId = parts[0]!;
|
|
77
77
|
const port = Number(parts[1]);
|
|
78
78
|
const token = parts.slice(2).join(":"); // token may contain colons
|
|
79
|
-
if (!
|
|
80
|
-
return { sessionId, port, token };
|
|
79
|
+
if (!hostDeviceId || isNaN(port) || port < 1 || port > 65535 || !token) return null;
|
|
80
|
+
return { hostDeviceId, sessionId: hostDeviceId, port, token };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function errorResponse(res: ServerResponse, status: number, message: string): void {
|
|
@@ -89,32 +89,45 @@ function errorResponse(res: ServerResponse, status: number, message: string): vo
|
|
|
89
89
|
res.end(message);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export function parseTunnelPath(pathname: string): { sessionId: string; port: number; path: string } | null {
|
|
92
|
+
export function parseTunnelPath(pathname: string): { hostDeviceId: string; sessionId: string; port: number; path: string } | null {
|
|
93
93
|
const match = pathname.match(/^\/tunnel\/([^/]+)\/(\d+)(\/.*)?$/);
|
|
94
94
|
if (!match) return null;
|
|
95
95
|
const port = Number(match[2]);
|
|
96
96
|
if (port < 1 || port > 65535) return null;
|
|
97
97
|
return {
|
|
98
|
+
hostDeviceId: match[1]!,
|
|
98
99
|
sessionId: match[1]!,
|
|
99
100
|
port,
|
|
100
101
|
path: match[3] || "/",
|
|
101
102
|
};
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
type ParsedTunnelTarget = {
|
|
106
|
+
hostDeviceId?: string;
|
|
107
|
+
sessionId?: string;
|
|
108
|
+
port: number;
|
|
109
|
+
path: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
104
112
|
export async function handleTunnelRequest(
|
|
105
113
|
req: IncomingMessage,
|
|
106
114
|
res: ServerResponse,
|
|
107
115
|
sessions: SessionManager,
|
|
108
116
|
tokens: TokenManager,
|
|
109
|
-
parsed:
|
|
117
|
+
parsed: ParsedTunnelTarget,
|
|
110
118
|
url: URL,
|
|
111
119
|
preAuthToken?: string,
|
|
112
120
|
): Promise<void> {
|
|
113
|
-
const
|
|
121
|
+
const hostDeviceId = parsed.hostDeviceId ?? parsed.sessionId;
|
|
122
|
+
const { port, path } = parsed;
|
|
123
|
+
if (!hostDeviceId) {
|
|
124
|
+
errorResponse(res, 400, "Missing host device id");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
114
127
|
|
|
115
128
|
// Auth: device token OR Supabase JWT (userId owns session)
|
|
116
129
|
const token = preAuthToken || extractToken(req, url);
|
|
117
|
-
const tokenOwns = token && tokens.owns(token,
|
|
130
|
+
const tokenOwns = token && tokens.owns(token, hostDeviceId);
|
|
118
131
|
let authOwns = false;
|
|
119
132
|
let authJwt: string | null = null;
|
|
120
133
|
if (!tokenOwns && AUTH_REQUIRED) {
|
|
@@ -135,7 +148,7 @@ export async function handleTunnelRequest(
|
|
|
135
148
|
});
|
|
136
149
|
if (userRes.ok) {
|
|
137
150
|
const user = (await userRes.json()) as { id: string };
|
|
138
|
-
const session = sessions.get(
|
|
151
|
+
const session = sessions.get(hostDeviceId);
|
|
139
152
|
if (user.id && session?.userId && user.id === session.userId) {
|
|
140
153
|
authOwns = true;
|
|
141
154
|
authJwt = jwtCandidate;
|
|
@@ -153,12 +166,12 @@ export async function handleTunnelRequest(
|
|
|
153
166
|
// Set auth cookie for subsequent sub-resource requests
|
|
154
167
|
const cookieToken = tokenOwns ? token : authJwt;
|
|
155
168
|
if (cookieToken) {
|
|
156
|
-
const cookieVal = encodeURIComponent(`${
|
|
169
|
+
const cookieVal = encodeURIComponent(`${hostDeviceId}:${port}:${cookieToken}`);
|
|
157
170
|
res.setHeader("Set-Cookie", `lsh_tunnel=${cookieVal}; Path=/; HttpOnly; SameSite=Lax`);
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
// Validate session & host
|
|
161
|
-
const session = sessions.get(
|
|
174
|
+
const session = sessions.get(hostDeviceId);
|
|
162
175
|
if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
|
|
163
176
|
errorResponse(res, 502, "Host not connected");
|
|
164
177
|
return;
|
|
@@ -204,17 +217,17 @@ export async function handleTunnelRequest(
|
|
|
204
217
|
headersSent: false,
|
|
205
218
|
timeout: setTimeout(() => {
|
|
206
219
|
pendingRequests.delete(requestId);
|
|
207
|
-
untrackRequest(
|
|
220
|
+
untrackRequest(hostDeviceId, requestId);
|
|
208
221
|
errorResponse(res, 504, "Tunnel request timed out");
|
|
209
222
|
}, TUNNEL_TIMEOUT),
|
|
210
223
|
};
|
|
211
224
|
pendingRequests.set(requestId, pending);
|
|
212
|
-
trackRequest(
|
|
225
|
+
trackRequest(hostDeviceId, requestId);
|
|
213
226
|
|
|
214
227
|
// Send tunnel.request to host
|
|
215
228
|
const envelope = createEnvelope({
|
|
216
229
|
type: "tunnel.request",
|
|
217
|
-
|
|
230
|
+
hostDeviceId,
|
|
218
231
|
payload: {
|
|
219
232
|
requestId,
|
|
220
233
|
method,
|
|
@@ -232,7 +245,7 @@ export async function handleTunnelRequest(
|
|
|
232
245
|
if (p) {
|
|
233
246
|
clearTimeout(p.timeout);
|
|
234
247
|
pendingRequests.delete(requestId);
|
|
235
|
-
untrackRequest(
|
|
248
|
+
untrackRequest(hostDeviceId, requestId);
|
|
236
249
|
}
|
|
237
250
|
});
|
|
238
251
|
}
|
|
@@ -306,8 +319,8 @@ export function removeTunnelWs(requestId: string): void {
|
|
|
306
319
|
pendingWsSockets.delete(requestId);
|
|
307
320
|
}
|
|
308
321
|
|
|
309
|
-
export function cleanupSessionTunnels(
|
|
310
|
-
const requestIds =
|
|
322
|
+
export function cleanupSessionTunnels(hostDeviceId: string): void {
|
|
323
|
+
const requestIds = deviceRequests.get(hostDeviceId);
|
|
311
324
|
if (!requestIds) return;
|
|
312
325
|
for (const rid of requestIds) {
|
|
313
326
|
const pending = pendingRequests.get(rid);
|
|
@@ -322,21 +335,26 @@ export function cleanupSessionTunnels(sessionId: string): void {
|
|
|
322
335
|
pendingWsSockets.delete(rid);
|
|
323
336
|
}
|
|
324
337
|
}
|
|
325
|
-
|
|
338
|
+
deviceRequests.delete(hostDeviceId);
|
|
326
339
|
}
|
|
327
340
|
|
|
328
341
|
export async function handleTunnelWsUpgrade(
|
|
329
342
|
ws: WebSocket,
|
|
330
|
-
parsed:
|
|
343
|
+
parsed: ParsedTunnelTarget,
|
|
331
344
|
url: URL,
|
|
332
345
|
sessions: SessionManager,
|
|
333
346
|
tokens: TokenManager,
|
|
334
347
|
): Promise<void> {
|
|
335
|
-
const
|
|
348
|
+
const hostDeviceId = parsed.hostDeviceId ?? parsed.sessionId;
|
|
349
|
+
const { port, path } = parsed;
|
|
350
|
+
if (!hostDeviceId) {
|
|
351
|
+
ws.close(1008, "Missing host device id");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
336
354
|
|
|
337
355
|
// Auth: device token OR Supabase JWT (userId owns session)
|
|
338
356
|
const token = url.searchParams.get("token");
|
|
339
|
-
const tokenOwns = token && tokens.owns(token,
|
|
357
|
+
const tokenOwns = token && tokens.owns(token, hostDeviceId);
|
|
340
358
|
let authOwns = false;
|
|
341
359
|
if (!tokenOwns && AUTH_REQUIRED) {
|
|
342
360
|
// Try auth_token param first, then fall back to token param (cookie fallback stores JWT there)
|
|
@@ -352,7 +370,7 @@ export async function handleTunnelWsUpgrade(
|
|
|
352
370
|
});
|
|
353
371
|
if (userRes.ok) {
|
|
354
372
|
const user = (await userRes.json()) as { id: string };
|
|
355
|
-
const session = sessions.get(
|
|
373
|
+
const session = sessions.get(hostDeviceId);
|
|
356
374
|
if (user.id && session?.userId && user.id === session.userId) {
|
|
357
375
|
authOwns = true;
|
|
358
376
|
}
|
|
@@ -366,7 +384,7 @@ export async function handleTunnelWsUpgrade(
|
|
|
366
384
|
return;
|
|
367
385
|
}
|
|
368
386
|
|
|
369
|
-
const session = sessions.get(
|
|
387
|
+
const session = sessions.get(hostDeviceId);
|
|
370
388
|
if (!session || !session.host || session.host.socket.readyState !== session.host.socket.OPEN) {
|
|
371
389
|
ws.close(4002, "Host not connected");
|
|
372
390
|
return;
|
|
@@ -377,12 +395,12 @@ export async function handleTunnelWsUpgrade(
|
|
|
377
395
|
|
|
378
396
|
// Register this WS so host responses route here
|
|
379
397
|
registerTunnelWs(requestId, ws);
|
|
380
|
-
trackRequest(
|
|
398
|
+
trackRequest(hostDeviceId, requestId);
|
|
381
399
|
|
|
382
400
|
// Send tunnel.request with upgrade header to host
|
|
383
401
|
const envelope = createEnvelope({
|
|
384
402
|
type: "tunnel.request",
|
|
385
|
-
|
|
403
|
+
hostDeviceId,
|
|
386
404
|
payload: {
|
|
387
405
|
requestId,
|
|
388
406
|
method: "GET",
|
|
@@ -397,13 +415,13 @@ export async function handleTunnelWsUpgrade(
|
|
|
397
415
|
// Forward data from browser WS to host
|
|
398
416
|
ws.on("message", (data: Buffer | string) => {
|
|
399
417
|
try {
|
|
400
|
-
const s = sessions.get(
|
|
418
|
+
const s = sessions.get(hostDeviceId);
|
|
401
419
|
if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
|
|
402
420
|
const isBinary = typeof data !== "string";
|
|
403
421
|
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
404
422
|
const fwd = createEnvelope({
|
|
405
423
|
type: "tunnel.ws.data",
|
|
406
|
-
|
|
424
|
+
hostDeviceId,
|
|
407
425
|
payload: {
|
|
408
426
|
requestId,
|
|
409
427
|
data: buf.toString("base64"),
|
|
@@ -417,13 +435,13 @@ export async function handleTunnelWsUpgrade(
|
|
|
417
435
|
ws.on("close", (code, reason) => {
|
|
418
436
|
try {
|
|
419
437
|
removeTunnelWs(requestId);
|
|
420
|
-
untrackRequest(
|
|
421
|
-
const s = sessions.get(
|
|
438
|
+
untrackRequest(hostDeviceId, requestId);
|
|
439
|
+
const s = sessions.get(hostDeviceId);
|
|
422
440
|
if (!s?.host || s.host.socket.readyState !== s.host.socket.OPEN) return;
|
|
423
441
|
const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
|
|
424
442
|
const fwd = createEnvelope({
|
|
425
443
|
type: "tunnel.ws.close",
|
|
426
|
-
|
|
444
|
+
hostDeviceId,
|
|
427
445
|
payload: {
|
|
428
446
|
requestId,
|
|
429
447
|
code: safeCode,
|